Merge remote-tracking branch 'upstream/main' into feat/profiles-and-alias
# Conflicts: # packages/coding-agent/src/cli/args.ts # packages/coding-agent/src/commands/launch.ts
This commit is contained in:
@@ -16,6 +16,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
# scripts/release.ts pushes the version-bump commit and its `v*` tag
|
||||
# atomically (`git push --atomic origin main refs/tags/v*`), so a release
|
||||
|
||||
@@ -70,6 +70,7 @@ const CP_KP_EQUALS: i32 = 57415;
|
||||
const MOD_SHIFT: u32 = 1;
|
||||
const MOD_ALT: u32 = 2;
|
||||
const MOD_CTRL: u32 = 4;
|
||||
const MOD_SUPER: u32 = 8;
|
||||
const MOD_NUM_LOCK: u32 = 128;
|
||||
|
||||
/// Event types from Kitty keyboard protocol (flag 2).
|
||||
@@ -457,6 +458,10 @@ fn parse_key_id(key_id: &str) -> Option<ParsedKeyId<'_>> {
|
||||
modifier |= MOD_SHIFT;
|
||||
continue;
|
||||
},
|
||||
b's' | b'S' if p.eq_ignore_ascii_case("super") => {
|
||||
modifier |= MOD_SUPER;
|
||||
continue;
|
||||
},
|
||||
b'a' | b'A' if p.eq_ignore_ascii_case("alt") => {
|
||||
modifier |= MOD_ALT;
|
||||
continue;
|
||||
@@ -1378,7 +1383,7 @@ fn parse_functional(bytes: &[u8]) -> Option<ParsedKittySequence> {
|
||||
|
||||
fn format_kitty_key(parsed: &ParsedKittySequence) -> Option<Cow<'static, str>> {
|
||||
let effective_mod = parsed.modifier & !LOCK_MASK;
|
||||
if effective_mod & !(MOD_SHIFT | MOD_CTRL | MOD_ALT) != 0 {
|
||||
if effective_mod & !(MOD_SHIFT | MOD_CTRL | MOD_ALT | MOD_SUPER) != 0 {
|
||||
return None;
|
||||
}
|
||||
let effective_codepoint =
|
||||
@@ -1480,6 +1485,9 @@ fn format_with_mods(mods: u32, key_name: &str) -> String {
|
||||
if mods & MOD_ALT != 0 {
|
||||
result.push_str("alt+");
|
||||
}
|
||||
if mods & MOD_SUPER != 0 {
|
||||
result.push_str("super+");
|
||||
}
|
||||
result.push_str(key_name);
|
||||
result
|
||||
}
|
||||
@@ -1585,7 +1593,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn parse_key_ignores_kitty_sequences_with_unsupported_modifiers() {
|
||||
assert_eq!(parse_key_inner(b"\x1b[99;9u", true).as_deref(), None);
|
||||
// Hyper (16) and meta (32) are kitty modifier bits we do not surface
|
||||
// because nothing in the editor binds them. Wire mod 17 = mask 16 = hyper.
|
||||
assert_eq!(parse_key_inner(b"\x1b[99;17u", true).as_deref(), None);
|
||||
// Wire mod 33 = mask 32 = meta.
|
||||
assert_eq!(parse_key_inner(b"\x1b[99;33u", true).as_deref(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1675,4 +1687,32 @@ mod tests {
|
||||
assert!(matches_key_inner(b"\x1b[109;7u", "ctrl+alt+m", true));
|
||||
assert!(matches_key_inner(b"\x1b[27;7;109~", "ctrl+alt+m", false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn super_alt_backspace_matches_ghostty_default() {
|
||||
// Issue #2064: Ghostty on macOS reports Option+Backspace as kitty
|
||||
// modifier 11 (wire) = 10 (mask) = super(8)|alt(2). Before super
|
||||
// support landed, the matcher rejected this entirely.
|
||||
assert!(matches_key_inner(b"\x1b[127;11u", "super+alt+backspace", true));
|
||||
assert!(matches_key_inner(b"\x1b[127;11u", "alt+super+backspace", true));
|
||||
assert_eq!(parse_key_inner(b"\x1b[127;11u", true).as_deref(), Some("alt+super+backspace"));
|
||||
// Plain alt+backspace must still NOT match — the modifier really is super|alt.
|
||||
assert!(!matches_key_inner(b"\x1b[127;11u", "alt+backspace", true));
|
||||
// And plain backspace (mod 0) must still not match a super+alt-modified press.
|
||||
assert!(!matches_key_inner(b"\x1b[127;11u", "backspace", true));
|
||||
// Release events stay ignored: super+alt+backspace release must not match a press.
|
||||
assert!(!matches_key_inner(b"\x1b[127;11:3u", "super+alt+backspace", true));
|
||||
assert_eq!(parse_key_inner(b"\x1b[127;11:3u", true).as_deref(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn super_modifier_parses_for_arbitrary_keys() {
|
||||
// Cmd+letter on macOS under kitty flag=1+: super(8)+'a'(97) → wire mod 9.
|
||||
assert!(matches_key_inner(b"\x1b[97;9u", "super+a", true));
|
||||
assert_eq!(parse_key_inner(b"\x1b[97;9u", true).as_deref(), Some("super+a"));
|
||||
// Cmd+Shift+letter: super(8)|shift(1) = 9 mask, wire 10.
|
||||
assert!(matches_key_inner(b"\x1b[97;10u", "super+shift+a", true));
|
||||
assert!(matches_key_inner(b"\x1b[97;10u", "shift+super+a", true));
|
||||
assert_eq!(parse_key_inner(b"\x1b[97;10u", true).as_deref(), Some("shift+super+a"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,13 +81,13 @@ These are consumed via `getEnvApiKey()` (`packages/ai/src/stream.ts`) unless not
|
||||
| `WAFER_SERVERLESS_API_KEY` | Wafer Serverless auth | Using `wafer-serverless` provider | Pay-as-you-go Wafer SKU; validated against `https://pass.wafer.ai/v1/models` |
|
||||
| `GITLAB_TOKEN` | GitLab Duo auth | Using `gitlab-duo` provider | |
|
||||
|
||||
### GitHub/Copilot token chains
|
||||
### GitHub/Copilot tokens
|
||||
|
||||
| Variable | Used for | Chain |
|
||||
| ---------------------- | ------------------------------------------------ | ---------------------------------------------------- |
|
||||
| `COPILOT_GITHUB_TOKEN` | GitHub Copilot provider auth | `COPILOT_GITHUB_TOKEN` → `GH_TOKEN` → `GITHUB_TOKEN` |
|
||||
| `GH_TOKEN` | Copilot fallback; GitHub API auth in web scraper | In web scraper: `GITHUB_TOKEN` → `GH_TOKEN` |
|
||||
| `GITHUB_TOKEN` | Copilot fallback; GitHub API auth in web scraper | In web scraper: checked before `GH_TOKEN` |
|
||||
| Variable | Used for | Notes |
|
||||
| ---------------------- | ------------------------------------------------ | ------------------------------------------ |
|
||||
| `COPILOT_GITHUB_TOKEN` | GitHub Copilot provider auth | Generic GitHub tokens are not used here |
|
||||
| `GH_TOKEN` | GitHub API auth in web scraper | Web scraper fallback after `GITHUB_TOKEN` |
|
||||
| `GITHUB_TOKEN` | GitHub API auth in web scraper | Web scraper checks this before `GH_TOKEN` |
|
||||
|
||||
### Auth broker / auth gateway (remote credential vault)
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ Upstream uses different package scopes. Replace them consistently.
|
||||
- `@mariozechner/pi-agent-core` → `@oh-my-pi/pi-agent-core`
|
||||
- `@mariozechner/pi-tui` → `@oh-my-pi/pi-tui`
|
||||
- `@mariozechner/pi-ai` → `@oh-my-pi/pi-ai`
|
||||
- `@mariozechner/pi-utils` → `@oh-my-pi/pi-utils`
|
||||
- Some upstream packages publish under the `@earendil-works/*` scope instead of `@mariozechner/*`. Map it the same way (`@earendil-works/pi-coding-agent` → `@oh-my-pi/pi-coding-agent`, and so on).
|
||||
- The bare `typebox` package is not an `@oh-my-pi/*` scope; do not rewrite it as one. See the Extensions divergence in section 15 for how tool-parameter schemas map.
|
||||
|
||||
## 4) Use Bun APIs where they improve on Node
|
||||
|
||||
@@ -353,10 +356,13 @@ Our fork has architectural decisions that differ from upstream. **Do not port th
|
||||
|
||||
### Extensions
|
||||
|
||||
| Upstream | Our Fork |
|
||||
| ----------------------------- | ------------------------------------------------- |
|
||||
| `jiti` for TypeScript loading | Native Bun `import()` |
|
||||
| `pkg.pi` manifest field | `pkg.omp` preferred; fallback to `pkg.pi` remains |
|
||||
| Upstream | Our Fork |
|
||||
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| `jiti` for TypeScript loading | Native Bun `import()` |
|
||||
| `pkg.pi` manifest field | `pkg.omp` preferred; fallback to `pkg.pi` remains |
|
||||
| `StringEnum` from `pi-ai` | `Type.Enum` from the `pi.typebox` shim (or author the schema with `pi.zod`); `pi-ai` no longer exports `StringEnum` |
|
||||
| `formatSize` from `pi-coding-agent` | `formatBytes` from `@oh-my-pi/pi-utils` |
|
||||
| `DefaultResourceLoader` / `DefaultPackageManager` / `SettingsManager` / `createEventBus` | Capability-based discovery (`loadCapability(...)`) plus the `Settings` singleton and `EventBus` |
|
||||
|
||||
### Skip These Upstream Features
|
||||
|
||||
|
||||
@@ -24,8 +24,8 @@ This document describes operator-visible behavior for session export/share/fork/
|
||||
| `--fork <id\|path>` | CLI startup | Yes after session creation | Creates a new session fork from the selected source into current cwd/session dir | None |
|
||||
| `/resume` | Interactive slash command | Yes (active in-memory state replaced) | Switches to selected existing session file | None |
|
||||
| `--resume` | CLI startup picker | Yes after session creation | Opens selected existing session file | None |
|
||||
| `--resume <id\|path>` | CLI startup | Yes after session creation | Opens existing session; global cross-project match can fork into current project | None |
|
||||
| `--continue` | CLI startup | Yes after session creation | Opens terminal breadcrumb or most-recent session; creates new one if none exists | None |
|
||||
| `--resume <id\|path>` | CLI startup | Yes after session creation | Opens existing session; global cross-project match re-roots (moved dir) or forks into current project | None |
|
||||
| `--continue` | CLI startup | Yes after session creation | Opens terminal breadcrumb (re-roots it if its dir was moved) or most-recent session; creates new one if none exists | None |
|
||||
|
||||
## Export and dump
|
||||
|
||||
@@ -215,19 +215,23 @@ Notes:
|
||||
|
||||
Cross-project id match behavior:
|
||||
|
||||
- If matched session cwd differs from current cwd, CLI asks:
|
||||
- `Session found in different project ... Fork into current directory? [y/N]`
|
||||
- On yes: `SessionManager.forkFrom(match.path, cwd, sessionDir)` creates a new local forked file.
|
||||
- On no/non-TTY default: command errors.
|
||||
- If matched session cwd differs from current cwd, behavior depends on whether the matched session's recorded directory still exists:
|
||||
- **Directory gone (moved/renamed, e.g. `git worktree move`)**: CLI asks `Session's directory no longer exists (...). Move (re-root) it into the current directory? [Y/n]`.
|
||||
- On yes (default): `SessionManager.open(match.path)` then `manager.moveTo(cwd)` re-roots the existing session into the current directory (no duplicate file).
|
||||
- On no: command cancels (returns no session). On non-TTY: command errors.
|
||||
- **Directory still exists (genuinely different project)**: CLI asks `Session found in different project ... Fork into current directory? [y/N]`.
|
||||
- On yes: `SessionManager.forkFrom(match.path, cwd, sessionDir)` creates a new local forked file.
|
||||
- On no: command cancels. On non-TTY: command errors.
|
||||
|
||||
## CLI `--continue`
|
||||
|
||||
`SessionManager.continueRecent(cwd, sessionDir)`:
|
||||
|
||||
1. Resolves session dir for current cwd.
|
||||
2. Reads terminal-scoped breadcrumb first.
|
||||
3. Falls back to most recently modified session file.
|
||||
4. Opens found session; if none exists, creates new session.
|
||||
2. Reads the terminal-scoped breadcrumb.
|
||||
3. If the breadcrumb points at a session recorded under a different cwd whose directory no longer exists (moved/renamed) **and** the current directory has no sessions of its own, re-roots that session into the current directory via `moveTo` instead of starting fresh.
|
||||
4. Otherwise, if the breadcrumb's cwd matches the current cwd, uses the breadcrumb session; else falls back to the most recently modified session file.
|
||||
5. Opens the found session; if none exists, creates a new session.
|
||||
|
||||
This is startup-only behavior; there is no interactive `/continue` slash command.
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for `impersonated_service_account` Application Default Credentials (ADC) in Vertex AI to enable chained impersonation without failing via 401 `invalid_client`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed duplicate upstream `tool_call_id` values collapsing distinct tool calls during message transformation, preserving one call/result pairing per emitted tool call before provider replay. ([#2055](https://github.com/can1357/oh-my-pi/issues/2055))
|
||||
|
||||
## [15.10.1] - 2026-06-07
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { Buffer } from "node:buffer";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { FetchImpl } from "../../types";
|
||||
import { __resetVertexTokenCache, getVertexAccessToken } from "../google-auth";
|
||||
|
||||
const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
|
||||
const JWT_BEARER_GRANT = "urn:ietf:params:oauth:grant-type:jwt-bearer";
|
||||
|
||||
/** Generate a real RS256 private key so signJwtRs256 / pemToPkcs8 run for real. */
|
||||
async function generateServiceAccountPem(): Promise<string> {
|
||||
const keyPair = (await globalThis.crypto.subtle.generateKey(
|
||||
{ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" },
|
||||
true,
|
||||
["sign", "verify"],
|
||||
)) as CryptoKeyPair;
|
||||
const pkcs8 = new Uint8Array(await globalThis.crypto.subtle.exportKey("pkcs8", keyPair.privateKey));
|
||||
const body = (
|
||||
Buffer.from(pkcs8)
|
||||
.toString("base64")
|
||||
.match(/.{1,64}/g) ?? []
|
||||
).join("\n");
|
||||
return `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----\n`;
|
||||
}
|
||||
|
||||
function urlOf(input: string | URL | Request): string {
|
||||
if (typeof input === "string") return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
return input.url;
|
||||
}
|
||||
|
||||
describe("getVertexAccessToken impersonated_service_account ADC", () => {
|
||||
let tmpDir: string;
|
||||
let originalGac: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
__resetVertexTokenCache();
|
||||
originalGac = Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-vertex-adc-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
__resetVertexTokenCache();
|
||||
if (originalGac === undefined) delete Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
else Bun.env.GOOGLE_APPLICATION_CREDENTIALS = originalGac;
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects a malformed service_account_impersonation_url before any network call", async () => {
|
||||
const adcPath = path.join(tmpDir, "impersonated-bad-url.json");
|
||||
await Bun.write(
|
||||
adcPath,
|
||||
JSON.stringify({
|
||||
type: "impersonated_service_account",
|
||||
// Missing the trailing ":generateAccessToken" the principal parser requires.
|
||||
service_account_impersonation_url:
|
||||
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com",
|
||||
source_credentials: {
|
||||
type: "authorized_user",
|
||||
client_id: "client-id",
|
||||
client_secret: "client-secret",
|
||||
refresh_token: "refresh-token",
|
||||
},
|
||||
}),
|
||||
);
|
||||
Bun.env.GOOGLE_APPLICATION_CREDENTIALS = adcPath;
|
||||
|
||||
const calls: string[] = [];
|
||||
const fetchImpl: FetchImpl = async input => {
|
||||
calls.push(urlOf(input));
|
||||
return new Response("{}");
|
||||
};
|
||||
|
||||
// The principal is parsed before the source exchange, so a bad URL must fail
|
||||
// up front rather than after burning a source-token round trip.
|
||||
await expect(getVertexAccessToken({ fetch: fetchImpl })).rejects.toBeInstanceOf(RangeError);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it("signs an RS256 JWT for a service_account source and reconstructs the IAM URL", async () => {
|
||||
const pem = await generateServiceAccountPem();
|
||||
const adcPath = path.join(tmpDir, "impersonated-sa.json");
|
||||
await Bun.write(
|
||||
adcPath,
|
||||
JSON.stringify({
|
||||
type: "impersonated_service_account",
|
||||
// Non-canonical project segment proves the request URL is rebuilt, not echoed.
|
||||
service_account_impersonation_url:
|
||||
"https://iamcredentials.googleapis.com/v1/projects/explicit-proj/serviceAccounts/target@project.iam.gserviceaccount.com:generateAccessToken",
|
||||
source_credentials: {
|
||||
type: "service_account",
|
||||
client_email: "source@project.iam.gserviceaccount.com",
|
||||
private_key: pem,
|
||||
private_key_id: "key-1",
|
||||
},
|
||||
// delegates intentionally omitted — the IAM body must default to [].
|
||||
}),
|
||||
);
|
||||
Bun.env.GOOGLE_APPLICATION_CREDENTIALS = adcPath;
|
||||
|
||||
const calls: { url: string; init?: RequestInit }[] = [];
|
||||
const fetchImpl: FetchImpl = async (input, init) => {
|
||||
const url = urlOf(input);
|
||||
calls.push({ url, init });
|
||||
if (url === "https://oauth2.googleapis.com/token") {
|
||||
return new Response(JSON.stringify({ access_token: "sa-source-token", expires_in: 3600 }));
|
||||
}
|
||||
if (url.startsWith("https://iamcredentials.googleapis.com/")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
accessToken: "impersonated-token",
|
||||
expireTime: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return new Response("unexpected", { status: 404 });
|
||||
};
|
||||
|
||||
const token = await getVertexAccessToken({ fetch: fetchImpl });
|
||||
expect(token).toBe("impersonated-token");
|
||||
|
||||
// Source JWT exchange happens first, then the impersonation exchange.
|
||||
expect(calls.map(c => c.url)).toEqual([
|
||||
"https://oauth2.googleapis.com/token",
|
||||
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com:generateAccessToken",
|
||||
]);
|
||||
|
||||
// Source credential is exchanged via a signed JWT bearer assertion, not a refresh grant.
|
||||
const sourceBody = new URLSearchParams(String(calls[0].init?.body));
|
||||
expect(sourceBody.get("grant_type")).toBe(JWT_BEARER_GRANT);
|
||||
expect((sourceBody.get("assertion") ?? "").split(".")).toHaveLength(3);
|
||||
|
||||
// The IAM call carries the source-derived bearer token and defaults delegates to [].
|
||||
const iamHeaders = calls[1].init?.headers as Record<string, string>;
|
||||
expect(iamHeaders.Authorization).toBe("Bearer sa-source-token");
|
||||
expect(JSON.parse(String(calls[1].init?.body))).toEqual({
|
||||
delegates: [],
|
||||
scope: [CLOUD_PLATFORM_SCOPE],
|
||||
lifetime: "3600s",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,7 +42,14 @@ interface AuthorizedUserCredentials {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
type AdcFileCredentials = ServiceAccountCredentials | AuthorizedUserCredentials;
|
||||
interface ImpersonatedServiceAccountCredentials {
|
||||
type: "impersonated_service_account";
|
||||
service_account_impersonation_url: string;
|
||||
source_credentials: AuthorizedUserCredentials | ServiceAccountCredentials;
|
||||
delegates?: string[];
|
||||
}
|
||||
|
||||
type AdcFileCredentials = ServiceAccountCredentials | AuthorizedUserCredentials | ImpersonatedServiceAccountCredentials;
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
@@ -196,10 +203,52 @@ async function resolveAccessTokenUncached(
|
||||
): Promise<{ source: string; token: TokenResponse }> {
|
||||
const adc = await loadAdcCredentials();
|
||||
if (adc) {
|
||||
const token =
|
||||
adc.creds.type === "service_account"
|
||||
? await exchangeJwtForToken(adc.creds, signal, fetchImpl)
|
||||
: await exchangeRefreshToken(adc.creds, signal, fetchImpl);
|
||||
const creds = adc.creds;
|
||||
let token: TokenResponse;
|
||||
|
||||
if (creds.type === "impersonated_service_account") {
|
||||
const targetPrincipalMatch = /(?<target>[^/]+):(generateAccessToken|generateIdToken)$/.exec(
|
||||
creds.service_account_impersonation_url,
|
||||
);
|
||||
const targetPrincipal = targetPrincipalMatch?.groups?.target;
|
||||
if (!targetPrincipal) {
|
||||
throw new RangeError(`Cannot extract target principal from ${creds.service_account_impersonation_url}`);
|
||||
}
|
||||
|
||||
const sourceToken =
|
||||
creds.source_credentials.type === "service_account"
|
||||
? await exchangeJwtForToken(creds.source_credentials, signal, fetchImpl)
|
||||
: await exchangeRefreshToken(creds.source_credentials, signal, fetchImpl);
|
||||
|
||||
const response = await fetchImpl(
|
||||
`https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${targetPrincipal}:generateAccessToken`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${sourceToken.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
delegates: creds.delegates ?? [],
|
||||
scope: [CLOUD_PLATFORM_SCOPE],
|
||||
lifetime: "3600s",
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(`Google Impersonation token exchange failed (${response.status}): ${detail}`);
|
||||
}
|
||||
const data = (await response.json()) as { accessToken: string; expireTime: string };
|
||||
const expiresIn = Math.max(0, Math.floor((new Date(data.expireTime).getTime() - Date.now()) / 1000));
|
||||
token = { access_token: data.accessToken, expires_in: expiresIn, token_type: "Bearer" };
|
||||
} else {
|
||||
token =
|
||||
creds.type === "service_account"
|
||||
? await exchangeJwtForToken(creds, signal, fetchImpl)
|
||||
: await exchangeRefreshToken(creds, signal, fetchImpl);
|
||||
}
|
||||
return { source: adc.source, token };
|
||||
}
|
||||
const metadata = await fetchMetadataToken(signal, fetchImpl);
|
||||
|
||||
@@ -537,7 +537,6 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
}
|
||||
stream.push({ type: "start", partial: output });
|
||||
|
||||
const parseMiniMaxThinkTags = model.provider === "minimax-code" || model.provider === "minimax-code-cn";
|
||||
// Some OpenAI-compatible DeepSeek hosts (including NVIDIA NIM and DeepSeek's
|
||||
// native API) leak chat-template tool-call markers in `delta.content` even
|
||||
// though tool calls are also surfaced structurally. Strip the leaked markers
|
||||
@@ -678,9 +677,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
||||
}
|
||||
};
|
||||
|
||||
const streamMarkupHealingPattern = getStreamMarkupHealingPattern(model.provider, model.id, {
|
||||
parseThinkingTags: parseMiniMaxThinkTags,
|
||||
});
|
||||
const streamMarkupHealingPattern = getStreamMarkupHealingPattern(model.provider, model.id);
|
||||
const streamMarkupHealing = streamMarkupHealingPattern
|
||||
? new StreamMarkupHealing({ pattern: streamMarkupHealingPattern })
|
||||
: undefined;
|
||||
|
||||
@@ -17,6 +17,96 @@ const enum ToolCallStatus {
|
||||
Aborted = 2,
|
||||
}
|
||||
|
||||
/**
|
||||
* Maximum tool-call id length the strictest replay provider accepts.
|
||||
*
|
||||
* Anthropic requires `^[a-zA-Z0-9_-]+$` with a 64-char cap; Google and Codex
|
||||
* `normalizeToolCallId` implementations cap individual id segments to the same
|
||||
* 64-char ceiling. Replacement ids minted here flow back through
|
||||
* `convertAnthropicMessages` (and friends) unchanged, so the `_dupN` suffix
|
||||
* MUST not push a normalized id past this bound.
|
||||
*/
|
||||
const MAX_TOOL_CALL_ID_LENGTH = 64;
|
||||
|
||||
function appendDuplicateSuffix(originalId: string, suffix: string): string {
|
||||
if (originalId.length + suffix.length <= MAX_TOOL_CALL_ID_LENGTH) return `${originalId}${suffix}`;
|
||||
const prefixBudget = Math.max(0, MAX_TOOL_CALL_ID_LENGTH - suffix.length);
|
||||
return `${originalId.slice(0, prefixBudget)}${suffix}`;
|
||||
}
|
||||
|
||||
type PendingToolResultRewrite = { replacementId: string } | undefined;
|
||||
|
||||
function deduplicateToolCallIds(messages: Message[]): Message[] {
|
||||
const seenToolCallIds = new Map<string, number>();
|
||||
const pendingToolResultRewrites = new Map<string, PendingToolResultRewrite[]>();
|
||||
|
||||
return messages.map(msg => {
|
||||
if (msg.role === "toolResult") {
|
||||
const rewrites = pendingToolResultRewrites.get(msg.toolCallId);
|
||||
if (!rewrites || rewrites.length === 0) return msg;
|
||||
|
||||
const rewrite = rewrites.shift();
|
||||
if (rewrites.length === 0) pendingToolResultRewrites.delete(msg.toolCallId);
|
||||
if (rewrite) return { ...msg, toolCallId: rewrite.replacementId };
|
||||
return msg;
|
||||
}
|
||||
|
||||
if (msg.role !== "assistant") return msg;
|
||||
|
||||
const enqueueToolResultRewrite = (id: string, rewrite: PendingToolResultRewrite): void => {
|
||||
const rewrites = pendingToolResultRewrites.get(id);
|
||||
if (rewrites) {
|
||||
rewrites.push(rewrite);
|
||||
return;
|
||||
}
|
||||
pendingToolResultRewrites.set(id, [rewrite]);
|
||||
};
|
||||
|
||||
// Ids this turn has already touched; used to scope the "drop carried-over
|
||||
// pending rewrites" semantics to the FIRST occurrence per turn so multiple
|
||||
// blocks of the same id within one turn still accumulate as duplicates.
|
||||
const idsTouchedInTurn = new Set<string>();
|
||||
let contentChanged = false;
|
||||
const content = msg.content.map(block => {
|
||||
if (block.type !== "toolCall") return block;
|
||||
|
||||
// Drop any pending rewrites carried over from a prior assistant turn
|
||||
// for this id on its first appearance this turn. When a later turn
|
||||
// re-emits the same id, the older duplicate call's expected result
|
||||
// never landed in time — the second pass synthesizes
|
||||
// "No result provided" for it, and the upcoming real result(id) must
|
||||
// route to one of THIS turn's calls. Without this guard the older
|
||||
// `_dup` id would steal the next result.
|
||||
if (!idsTouchedInTurn.has(block.id)) {
|
||||
pendingToolResultRewrites.delete(block.id);
|
||||
idsTouchedInTurn.add(block.id);
|
||||
}
|
||||
|
||||
const previousCount = seenToolCallIds.get(block.id) ?? 0;
|
||||
if (previousCount === 0) {
|
||||
seenToolCallIds.set(block.id, 1);
|
||||
enqueueToolResultRewrite(block.id, undefined);
|
||||
return block;
|
||||
}
|
||||
|
||||
let duplicateIndex = previousCount;
|
||||
let replacementId = appendDuplicateSuffix(block.id, `_dup${duplicateIndex}`);
|
||||
while (seenToolCallIds.has(replacementId)) {
|
||||
duplicateIndex += 1;
|
||||
replacementId = appendDuplicateSuffix(block.id, `_dup${duplicateIndex}`);
|
||||
}
|
||||
seenToolCallIds.set(block.id, duplicateIndex + 1);
|
||||
seenToolCallIds.set(replacementId, 1);
|
||||
enqueueToolResultRewrite(block.id, { replacementId });
|
||||
contentChanged = true;
|
||||
return { ...block, id: replacementId };
|
||||
});
|
||||
|
||||
if (!contentChanged) return msg;
|
||||
return { ...msg, content };
|
||||
});
|
||||
}
|
||||
|
||||
function shouldDropTruncatedThinkingOnlyAssistant(msg: AssistantMessage): boolean {
|
||||
const isTruncatedStop = msg.stopReason === "length" || msg.stopReason === "error" || msg.stopReason === "aborted";
|
||||
return isTruncatedStop && !msg.content.some(block => block.type === "toolCall" || block.type === "text");
|
||||
@@ -52,116 +142,120 @@ export function transformMessages<TApi extends Api>(
|
||||
|
||||
const latestSurvivingAssistantIndex = getLatestSurvivingAssistantIndex(messages);
|
||||
// First pass: transform messages (thinking blocks, tool call ID normalization)
|
||||
const transformed = messages.map((msg, index) => {
|
||||
// User and developer messages pass through unchanged
|
||||
if (msg.role === "user" || msg.role === "developer") {
|
||||
return msg;
|
||||
}
|
||||
const transformed = deduplicateToolCallIds(
|
||||
messages.map((msg, index) => {
|
||||
// User and developer messages pass through unchanged
|
||||
if (msg.role === "user" || msg.role === "developer") {
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Handle toolResult messages - normalize toolCallId if we have a mapping
|
||||
if (msg.role === "toolResult") {
|
||||
const normalizedId = toolCallIdMap.get(msg.toolCallId);
|
||||
if (normalizedId && normalizedId !== msg.toolCallId) {
|
||||
return { ...msg, toolCallId: normalizedId };
|
||||
// Handle toolResult messages - normalize toolCallId if we have a mapping
|
||||
if (msg.role === "toolResult") {
|
||||
const normalizedId = toolCallIdMap.get(msg.toolCallId);
|
||||
if (normalizedId && normalizedId !== msg.toolCallId) {
|
||||
return { ...msg, toolCallId: normalizedId };
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Assistant messages need transformation check
|
||||
if (msg.role === "assistant") {
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
const isSameModel =
|
||||
assistantMsg.provider === model.provider &&
|
||||
assistantMsg.api === model.api &&
|
||||
assistantMsg.model === model.id;
|
||||
|
||||
const mustPreserveLatestAnthropicThinking =
|
||||
index === latestSurvivingAssistantIndex &&
|
||||
model.api === "anthropic-messages" &&
|
||||
assistantMsg.api === "anthropic-messages";
|
||||
// Aborted/errored messages may have partially-streamed thinking signatures.
|
||||
// A partial signature is invalid and will be rejected by the API, so we must
|
||||
// strip signatures from thinking blocks in these messages.
|
||||
//
|
||||
// Abandoned tool-use turns get the same treatment once they are no longer
|
||||
// the latest assistant message. When a turn carries toolCall blocks but did
|
||||
// NOT request tool execution (stopReason !== "toolUse" — e.g.
|
||||
// adaptive-thinking Opus emitting tool calls and then ending the turn on
|
||||
// `end_turn`/`stop`), the agent loop pairs those calls with placeholder
|
||||
// tool_results to keep the tool_use/tool_result contract valid. Historical
|
||||
// abandoned turns cannot safely replay their end_turn-bound signatures in
|
||||
// that continuation, so stripping downgrades them to plain text downstream.
|
||||
// Latest abandoned turns are exempt because Anthropic requires thinking
|
||||
// blocks from its most recent response to remain byte-for-byte unmodified.
|
||||
const invalidStopReason = assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error";
|
||||
const abandonedToolUse =
|
||||
!invalidStopReason &&
|
||||
assistantMsg.stopReason !== "toolUse" &&
|
||||
assistantMsg.content.some(b => b.type === "toolCall");
|
||||
const hasInvalidSignatures = invalidStopReason || abandonedToolUse;
|
||||
|
||||
const transformedContent = assistantMsg.content.flatMap(block => {
|
||||
if (block.type === "thinking") {
|
||||
// Strip untrustworthy signatures so the encoder can downgrade to text.
|
||||
const sanitized =
|
||||
hasInvalidSignatures && block.thinkingSignature
|
||||
? { ...block, thinkingSignature: undefined }
|
||||
: block;
|
||||
if (mustPreserveLatestAnthropicThinking) return abandonedToolUse ? block : sanitized;
|
||||
// For same model: keep thinking blocks with signatures (needed for replay)
|
||||
// even if the thinking text is empty (OpenAI encrypted reasoning)
|
||||
if (isSameModel && sanitized.thinkingSignature) return sanitized;
|
||||
// Skip empty thinking blocks, convert others to plain text
|
||||
if (!sanitized.thinking || sanitized.thinking.trim() === "") return [];
|
||||
if (isSameModel) return sanitized;
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: sanitized.thinking,
|
||||
};
|
||||
}
|
||||
|
||||
if (block.type === "redactedThinking") {
|
||||
if (mustPreserveLatestAnthropicThinking) return block;
|
||||
if (isSameModel) return block;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (block.type === "text") {
|
||||
if (isSameModel) return block;
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: block.text,
|
||||
};
|
||||
}
|
||||
|
||||
if (block.type === "toolCall") {
|
||||
const toolCall = block as ToolCall;
|
||||
let normalizedToolCall: ToolCall = toolCall;
|
||||
|
||||
if (!isSameModel && toolCall.thoughtSignature) {
|
||||
normalizedToolCall = { ...toolCall };
|
||||
delete (normalizedToolCall as { thoughtSignature?: string }).thoughtSignature;
|
||||
}
|
||||
|
||||
if (!isSameModel && normalizeToolCallId) {
|
||||
const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg);
|
||||
if (normalizedId !== toolCall.id) {
|
||||
toolCallIdMap.set(toolCall.id, normalizedId);
|
||||
normalizedToolCall = { ...normalizedToolCall, id: normalizedId };
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedToolCall;
|
||||
}
|
||||
|
||||
return block;
|
||||
});
|
||||
|
||||
return {
|
||||
...assistantMsg,
|
||||
content: transformedContent,
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Assistant messages need transformation check
|
||||
if (msg.role === "assistant") {
|
||||
const assistantMsg = msg as AssistantMessage;
|
||||
const isSameModel =
|
||||
assistantMsg.provider === model.provider &&
|
||||
assistantMsg.api === model.api &&
|
||||
assistantMsg.model === model.id;
|
||||
|
||||
const mustPreserveLatestAnthropicThinking =
|
||||
index === latestSurvivingAssistantIndex &&
|
||||
model.api === "anthropic-messages" &&
|
||||
assistantMsg.api === "anthropic-messages";
|
||||
// Aborted/errored messages may have partially-streamed thinking signatures.
|
||||
// A partial signature is invalid and will be rejected by the API, so we must
|
||||
// strip signatures from thinking blocks in these messages.
|
||||
//
|
||||
// Abandoned tool-use turns get the same treatment once they are no longer
|
||||
// the latest assistant message. When a turn carries toolCall blocks but did
|
||||
// NOT request tool execution (stopReason !== "toolUse" — e.g.
|
||||
// adaptive-thinking Opus emitting tool calls and then ending the turn on
|
||||
// `end_turn`/`stop`), the agent loop pairs those calls with placeholder
|
||||
// tool_results to keep the tool_use/tool_result contract valid. Historical
|
||||
// abandoned turns cannot safely replay their end_turn-bound signatures in
|
||||
// that continuation, so stripping downgrades them to plain text downstream.
|
||||
// Latest abandoned turns are exempt because Anthropic requires thinking
|
||||
// blocks from its most recent response to remain byte-for-byte unmodified.
|
||||
const invalidStopReason = assistantMsg.stopReason === "aborted" || assistantMsg.stopReason === "error";
|
||||
const abandonedToolUse =
|
||||
!invalidStopReason &&
|
||||
assistantMsg.stopReason !== "toolUse" &&
|
||||
assistantMsg.content.some(b => b.type === "toolCall");
|
||||
const hasInvalidSignatures = invalidStopReason || abandonedToolUse;
|
||||
|
||||
const transformedContent = assistantMsg.content.flatMap(block => {
|
||||
if (block.type === "thinking") {
|
||||
// Strip untrustworthy signatures so the encoder can downgrade to text.
|
||||
const sanitized =
|
||||
hasInvalidSignatures && block.thinkingSignature ? { ...block, thinkingSignature: undefined } : block;
|
||||
if (mustPreserveLatestAnthropicThinking) return abandonedToolUse ? block : sanitized;
|
||||
// For same model: keep thinking blocks with signatures (needed for replay)
|
||||
// even if the thinking text is empty (OpenAI encrypted reasoning)
|
||||
if (isSameModel && sanitized.thinkingSignature) return sanitized;
|
||||
// Skip empty thinking blocks, convert others to plain text
|
||||
if (!sanitized.thinking || sanitized.thinking.trim() === "") return [];
|
||||
if (isSameModel) return sanitized;
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: sanitized.thinking,
|
||||
};
|
||||
}
|
||||
|
||||
if (block.type === "redactedThinking") {
|
||||
if (mustPreserveLatestAnthropicThinking) return block;
|
||||
if (isSameModel) return block;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (block.type === "text") {
|
||||
if (isSameModel) return block;
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: block.text,
|
||||
};
|
||||
}
|
||||
|
||||
if (block.type === "toolCall") {
|
||||
const toolCall = block as ToolCall;
|
||||
let normalizedToolCall: ToolCall = toolCall;
|
||||
|
||||
if (!isSameModel && toolCall.thoughtSignature) {
|
||||
normalizedToolCall = { ...toolCall };
|
||||
delete (normalizedToolCall as { thoughtSignature?: string }).thoughtSignature;
|
||||
}
|
||||
|
||||
if (!isSameModel && normalizeToolCallId) {
|
||||
const normalizedId = normalizeToolCallId(toolCall.id, model, assistantMsg);
|
||||
if (normalizedId !== toolCall.id) {
|
||||
toolCallIdMap.set(toolCall.id, normalizedId);
|
||||
normalizedToolCall = { ...normalizedToolCall, id: normalizedId };
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedToolCall;
|
||||
}
|
||||
|
||||
return block;
|
||||
});
|
||||
|
||||
return {
|
||||
...assistantMsg,
|
||||
content: transformedContent,
|
||||
};
|
||||
}
|
||||
return msg;
|
||||
});
|
||||
}),
|
||||
);
|
||||
const realToolResultsById = new Map<string, ToolResultMessage>();
|
||||
for (const msg of transformed) {
|
||||
if (msg.role === "toolResult" && !realToolResultsById.has(msg.toolCallId)) {
|
||||
|
||||
@@ -209,8 +209,7 @@ const serviceProviderMap: Record<string, KeyResolver> = {
|
||||
tavily: "TAVILY_API_KEY",
|
||||
parallel: "PARALLEL_API_KEY",
|
||||
kagi: "KAGI_API_KEY",
|
||||
// GitHub Copilot uses GitHub personal access token
|
||||
"github-copilot": () => $pickenv("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"),
|
||||
"github-copilot": "COPILOT_GITHUB_TOKEN",
|
||||
// Foundry mode optionally switches Anthropic auth to enterprise gateway credentials.
|
||||
anthropic: () =>
|
||||
isFoundryEnabled()
|
||||
|
||||
@@ -600,12 +600,17 @@ export function modelMayLeakDsmlToolCalls(provider: string, modelId: string): bo
|
||||
);
|
||||
}
|
||||
|
||||
/** Cheap model/provider gate for MiniMax plain thinking tag leaks. */
|
||||
export function modelMayLeakThinkingTags(provider: string, modelId: string): boolean {
|
||||
return /minimax/i.test(provider) || /minimax/i.test(modelId);
|
||||
}
|
||||
|
||||
export function getStreamMarkupHealingPattern(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
options?: { readonly parseThinkingTags?: boolean },
|
||||
): StreamMarkupHealingPattern | undefined {
|
||||
if (options?.parseThinkingTags) return "thinking";
|
||||
if (options?.parseThinkingTags || modelMayLeakThinkingTags(provider, modelId)) return "thinking";
|
||||
if (modelMayLeakKimiToolCalls(provider, modelId)) return "kimi";
|
||||
if (modelMayLeakDsmlToolCalls(provider, modelId)) return "dsml";
|
||||
return undefined;
|
||||
|
||||
@@ -32,6 +32,43 @@ describe("Duplicate Tool Results Regression", () => {
|
||||
reasoning: true,
|
||||
};
|
||||
|
||||
const makeEvalAssistantMessage = (id: string, timestamp: number): AssistantMessage => ({
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id, name: "eval", arguments: {} }],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const makeEvalToolResult = (id: string, text: string, timestamp: number): ToolResultMessage => ({
|
||||
role: "toolResult",
|
||||
toolCallId: id,
|
||||
toolName: "eval",
|
||||
content: [{ type: "text", text }],
|
||||
isError: false,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
const getAssistantToolIds = (messages: Message[]): string[] =>
|
||||
messages.flatMap(message =>
|
||||
message.role === "assistant"
|
||||
? message.content.filter((block): block is ToolCall => block.type === "toolCall").map(block => block.id)
|
||||
: [],
|
||||
);
|
||||
|
||||
const getToolResults = (messages: Message[]): ToolResultMessage[] =>
|
||||
messages.filter((message): message is ToolResultMessage => message.role === "toolResult");
|
||||
|
||||
it("should not duplicate tool results for errored messages when results already exist", () => {
|
||||
const toolCallId = "toolu_019xqMTvqWZiTDy8XxmjxrTo";
|
||||
|
||||
@@ -316,6 +353,144 @@ describe("Duplicate Tool Results Regression", () => {
|
||||
expect(result2.length).toBe(1);
|
||||
expect(result3.length).toBe(1);
|
||||
});
|
||||
|
||||
it("deduplicates repeated tool call ids and preserves call/result pairing", () => {
|
||||
const duplicateId = "functions.eval:301";
|
||||
const distinctId = "functions.eval:302";
|
||||
|
||||
const messages: Message[] = [
|
||||
makeEvalAssistantMessage(duplicateId, 1),
|
||||
makeEvalToolResult(duplicateId, "first", 2),
|
||||
makeEvalAssistantMessage(duplicateId, 3),
|
||||
makeEvalToolResult(duplicateId, "second", 4),
|
||||
makeEvalAssistantMessage(duplicateId, 5),
|
||||
makeEvalAssistantMessage(distinctId, 6),
|
||||
makeEvalToolResult(distinctId, "third", 7),
|
||||
];
|
||||
|
||||
const transformed = transformMessages(messages, model);
|
||||
const assistantToolIds = getAssistantToolIds(transformed);
|
||||
const toolResults = getToolResults(transformed);
|
||||
|
||||
expect(assistantToolIds).toEqual([duplicateId, `${duplicateId}_dup1`, `${duplicateId}_dup2`, distinctId]);
|
||||
expect(toolResults.map(result => result.toolCallId)).toEqual([
|
||||
duplicateId,
|
||||
`${duplicateId}_dup1`,
|
||||
`${duplicateId}_dup2`,
|
||||
distinctId,
|
||||
]);
|
||||
expect(toolResults.find(result => result.toolCallId === `${duplicateId}_dup1`)?.content).toEqual([
|
||||
{ type: "text", text: "second" },
|
||||
]);
|
||||
expect(toolResults.find(result => result.toolCallId === `${duplicateId}_dup2`)?.content).toEqual([
|
||||
{ type: "text", text: "No result provided" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("deduplicates repeated ids without colliding with existing generated-looking ids", () => {
|
||||
const duplicateId = "functions.eval:301";
|
||||
const generatedLookingId = `${duplicateId}_dup1`;
|
||||
const messages: Message[] = [
|
||||
makeEvalAssistantMessage(duplicateId, 1),
|
||||
makeEvalToolResult(duplicateId, "first", 2),
|
||||
makeEvalAssistantMessage(generatedLookingId, 3),
|
||||
makeEvalToolResult(generatedLookingId, "already-used", 4),
|
||||
makeEvalAssistantMessage(duplicateId, 5),
|
||||
makeEvalToolResult(duplicateId, "second", 6),
|
||||
];
|
||||
|
||||
const transformed = transformMessages(messages, model);
|
||||
const assistantToolIds = getAssistantToolIds(transformed);
|
||||
const toolResults = getToolResults(transformed);
|
||||
|
||||
expect(assistantToolIds).toEqual([duplicateId, generatedLookingId, `${duplicateId}_dup2`]);
|
||||
expect(toolResults.map(result => result.toolCallId)).toEqual([
|
||||
duplicateId,
|
||||
generatedLookingId,
|
||||
`${duplicateId}_dup2`,
|
||||
]);
|
||||
expect(toolResults.find(result => result.toolCallId === `${duplicateId}_dup2`)?.content).toEqual([
|
||||
{ type: "text", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves delayed duplicate tool results across message gaps", () => {
|
||||
const duplicateId = "functions.eval:301";
|
||||
const developerMessage: DeveloperMessage = { role: "developer", content: "handoff summary", timestamp: 4 };
|
||||
const messages: Message[] = [
|
||||
makeEvalAssistantMessage(duplicateId, 1),
|
||||
makeEvalToolResult(duplicateId, "first", 2),
|
||||
makeEvalAssistantMessage(duplicateId, 3),
|
||||
developerMessage,
|
||||
makeEvalToolResult(duplicateId, "second", 5),
|
||||
];
|
||||
|
||||
const transformed = transformMessages(messages, model);
|
||||
const toolResults = getToolResults(transformed);
|
||||
|
||||
expect(getAssistantToolIds(transformed)).toEqual([duplicateId, `${duplicateId}_dup1`]);
|
||||
expect(toolResults.map(result => result.toolCallId)).toEqual([duplicateId, `${duplicateId}_dup1`]);
|
||||
expect(toolResults.find(result => result.toolCallId === `${duplicateId}_dup1`)?.content).toEqual([
|
||||
{ type: "text", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes the late result to the most recent duplicate call when a new turn re-emits the id across a gap", () => {
|
||||
const duplicateId = "functions.eval:301";
|
||||
const developerMessage: DeveloperMessage = { role: "developer", content: "handoff summary", timestamp: 4 };
|
||||
const messages: Message[] = [
|
||||
makeEvalAssistantMessage(duplicateId, 1),
|
||||
makeEvalToolResult(duplicateId, "first", 2),
|
||||
makeEvalAssistantMessage(duplicateId, 3),
|
||||
developerMessage,
|
||||
makeEvalAssistantMessage(duplicateId, 5),
|
||||
makeEvalToolResult(duplicateId, "second", 6),
|
||||
];
|
||||
|
||||
const transformed = transformMessages(messages, model);
|
||||
const toolResults = getToolResults(transformed);
|
||||
|
||||
expect(getAssistantToolIds(transformed)).toEqual([duplicateId, `${duplicateId}_dup1`, `${duplicateId}_dup2`]);
|
||||
expect(toolResults.map(result => result.toolCallId)).toEqual([
|
||||
duplicateId,
|
||||
`${duplicateId}_dup1`,
|
||||
`${duplicateId}_dup2`,
|
||||
]);
|
||||
expect(toolResults.find(result => result.toolCallId === `${duplicateId}_dup1`)?.content).toEqual([
|
||||
{ type: "text", text: "No result provided" },
|
||||
]);
|
||||
expect(toolResults.find(result => result.toolCallId === `${duplicateId}_dup2`)?.content).toEqual([
|
||||
{ type: "text", text: "second" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps duplicate-id rewrites within the 64-char tool-call id limit", () => {
|
||||
const baseId = `toolu_${"a".repeat(58)}`;
|
||||
expect(baseId.length).toBe(64);
|
||||
const messages: Message[] = [
|
||||
makeEvalAssistantMessage(baseId, 1),
|
||||
makeEvalToolResult(baseId, "first", 2),
|
||||
makeEvalAssistantMessage(baseId, 3),
|
||||
makeEvalToolResult(baseId, "second", 4),
|
||||
];
|
||||
|
||||
const transformed = transformMessages(messages, model);
|
||||
const assistantToolIds = getAssistantToolIds(transformed);
|
||||
const toolResults = getToolResults(transformed);
|
||||
|
||||
expect(assistantToolIds).toHaveLength(2);
|
||||
for (const id of assistantToolIds) {
|
||||
expect(id.length).toBeLessThanOrEqual(64);
|
||||
expect(id).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
}
|
||||
const rewrittenId = assistantToolIds[1];
|
||||
expect(rewrittenId).not.toBe(baseId);
|
||||
expect(rewrittenId.endsWith("_dup1")).toBe(true);
|
||||
expect(toolResults.map(result => result.toolCallId)).toEqual([baseId, rewrittenId]);
|
||||
expect(toolResults.find(result => result.toolCallId === rewrittenId)?.content).toEqual([
|
||||
{ type: "text", text: "second" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -148,6 +148,7 @@ describe("StreamMarkupHealing pattern selection", () => {
|
||||
expect(getStreamMarkupHealingPattern("minimax-code", "MiniMax-M2.5", { parseThinkingTags: true })).toBe(
|
||||
"thinking",
|
||||
);
|
||||
expect(getStreamMarkupHealingPattern("opencode-zen", "minimax-m3")).toBe("thinking");
|
||||
expect(getStreamMarkupHealingPattern("nanogpt", "deepseek/deepseek-v4-pro")).toBe("dsml");
|
||||
expect(getStreamMarkupHealingPattern("ollama-cloud", "gpt-oss:120b")).toBeUndefined();
|
||||
expect(getStreamMarkupHealingPattern("openai", "deepseek-v4-pro")).toBeUndefined();
|
||||
@@ -582,6 +583,39 @@ describe("Ollama provider DSML envelope healing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAI completions MiniMax thinking healing", () => {
|
||||
it("parses OpenCode Zen MiniMax think tags into a thinking block", async () => {
|
||||
const model: Model<"openai-completions"> = {
|
||||
id: "minimax-m3",
|
||||
name: "MiniMax M3",
|
||||
api: "openai-completions",
|
||||
provider: "opencode-zen",
|
||||
baseUrl: "https://opencode.ai/zen/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
global.fetch = mockFetch([
|
||||
chunk(model.id, { content: "visible <thin" }),
|
||||
chunk(model.id, { content: "k>hidden reasoning</think" }),
|
||||
chunk(model.id, { content: ">" }),
|
||||
chunk(model.id, { content: " answer" }),
|
||||
chunk(model.id, {}, "stop"),
|
||||
"[DONE]",
|
||||
]);
|
||||
|
||||
const result = await streamOpenAICompletions(model, baseContext(), { apiKey: "test-key" }).result();
|
||||
|
||||
expect(result.content).toEqual([
|
||||
{ type: "text", text: "visible " },
|
||||
{ type: "thinking", thinking: "hidden reasoning", thinkingSignature: undefined },
|
||||
{ type: "text", text: " answer" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenAI completions provider DSML envelope healing", () => {
|
||||
it("heals the envelope into a structured tool call and suppresses leaked text", async () => {
|
||||
const model: Model<"openai-completions"> = {
|
||||
|
||||
@@ -652,6 +652,138 @@ describe("Generate E2E Tests", () => {
|
||||
else Bun.env.GOOGLE_APPLICATION_CREDENTIALS = originalGac;
|
||||
}
|
||||
});
|
||||
|
||||
it("routes impersonated_service_account ADC through IAM to the Vertex request", async () => {
|
||||
const originalProject = Bun.env.GOOGLE_CLOUD_PROJECT;
|
||||
const originalGcpProject = Bun.env.GCP_PROJECT;
|
||||
const originalGcloudProject = Bun.env.GCLOUD_PROJECT;
|
||||
const originalVertexLocation = Bun.env.GOOGLE_VERTEX_LOCATION;
|
||||
const originalCloudLocation = Bun.env.GOOGLE_CLOUD_LOCATION;
|
||||
const originalLocation = Bun.env.VERTEX_LOCATION;
|
||||
const originalApiKey = Bun.env.GOOGLE_CLOUD_API_KEY;
|
||||
const originalGac = Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-vertex-impersonation-"));
|
||||
const adcPath = path.join(tmpDir, "impersonated-adc.json");
|
||||
await Bun.write(
|
||||
adcPath,
|
||||
JSON.stringify({
|
||||
type: "impersonated_service_account",
|
||||
service_account_impersonation_url:
|
||||
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com:generateAccessToken",
|
||||
source_credentials: {
|
||||
type: "authorized_user",
|
||||
client_id: "client-id",
|
||||
client_secret: "client-secret",
|
||||
refresh_token: "refresh-token",
|
||||
},
|
||||
delegates: ["projects/-/serviceAccounts/delegate@project.iam.gserviceaccount.com"],
|
||||
}),
|
||||
);
|
||||
const model: Model<"anthropic-messages"> = {
|
||||
id: "claude-sonnet-4@20250514",
|
||||
name: "Claude Sonnet 4",
|
||||
api: "anthropic-messages",
|
||||
provider: "google-vertex",
|
||||
baseUrl:
|
||||
"https://{location}-aiplatform.googleapis.com/v1/projects/{project}/locations/{location}/publishers/anthropic/models/claude-sonnet-4@20250514:streamRawPredict",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 64_000,
|
||||
};
|
||||
const callOrder: string[] = [];
|
||||
let iamRequest: { url: string; authorization: string | null; body: unknown } | undefined;
|
||||
const captured = Promise.withResolvers<{ url: string; authorization: string | null }>();
|
||||
|
||||
try {
|
||||
__resetVertexTokenCache();
|
||||
Bun.env.GOOGLE_CLOUD_PROJECT = "vertex-project";
|
||||
Bun.env.GOOGLE_VERTEX_LOCATION = "global";
|
||||
delete Bun.env.GCP_PROJECT;
|
||||
delete Bun.env.GCLOUD_PROJECT;
|
||||
delete Bun.env.GOOGLE_CLOUD_LOCATION;
|
||||
delete Bun.env.VERTEX_LOCATION;
|
||||
delete Bun.env.GOOGLE_CLOUD_API_KEY;
|
||||
Bun.env.GOOGLE_APPLICATION_CREDENTIALS = adcPath;
|
||||
|
||||
const events = stream(
|
||||
model,
|
||||
{ messages: [{ role: "user", content: "Hello", timestamp: Date.now() }] },
|
||||
{
|
||||
apiKey: "<authenticated>",
|
||||
fetch: async (input, init) => {
|
||||
const url = input instanceof Request ? input.url : input.toString();
|
||||
const headers = input instanceof Request ? input.headers : new Headers(init?.headers);
|
||||
if (url === "https://oauth2.googleapis.com/token") {
|
||||
callOrder.push("source");
|
||||
return new Response(JSON.stringify({ access_token: "source-token", expires_in: 3600 }));
|
||||
}
|
||||
if (url.startsWith("https://iamcredentials.googleapis.com/")) {
|
||||
callOrder.push("iam");
|
||||
const bodyText =
|
||||
input instanceof Request ? await input.clone().text() : String(init?.body ?? "");
|
||||
iamRequest = { url, authorization: headers.get("authorization"), body: JSON.parse(bodyText) };
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
accessToken: "impersonated-token",
|
||||
expireTime: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
callOrder.push("vertex");
|
||||
captured.resolve({ url, authorization: headers.get("authorization") });
|
||||
return new Response(JSON.stringify({ error: { message: "stop after capture" } }), { status: 400 });
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
for await (const _event of events) {
|
||||
}
|
||||
|
||||
const request = await captured.promise;
|
||||
|
||||
// Source refresh, then IAM generateAccessToken, then the actual Vertex call.
|
||||
expect(callOrder).toEqual(["source", "iam", "vertex"]);
|
||||
|
||||
// IAM exchange is authorized by the freshly minted source token, posts the
|
||||
// reconstructed canonical URL, and forwards the configured delegates verbatim.
|
||||
expect(iamRequest?.url).toBe(
|
||||
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/target@project.iam.gserviceaccount.com:generateAccessToken",
|
||||
);
|
||||
expect(iamRequest?.authorization).toBe("Bearer source-token");
|
||||
expect(iamRequest?.body).toEqual({
|
||||
delegates: ["projects/-/serviceAccounts/delegate@project.iam.gserviceaccount.com"],
|
||||
scope: ["https://www.googleapis.com/auth/cloud-platform"],
|
||||
lifetime: "3600s",
|
||||
});
|
||||
|
||||
// The impersonated token (not the source token) authorizes the Vertex request.
|
||||
expect(request.url).toBe(
|
||||
"https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/publishers/anthropic/models/claude-sonnet-4@20250514:streamRawPredict",
|
||||
);
|
||||
expect(request.authorization).toBe("Bearer impersonated-token");
|
||||
} finally {
|
||||
__resetVertexTokenCache();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
if (originalProject === undefined) delete Bun.env.GOOGLE_CLOUD_PROJECT;
|
||||
else Bun.env.GOOGLE_CLOUD_PROJECT = originalProject;
|
||||
if (originalGcpProject === undefined) delete Bun.env.GCP_PROJECT;
|
||||
else Bun.env.GCP_PROJECT = originalGcpProject;
|
||||
if (originalGcloudProject === undefined) delete Bun.env.GCLOUD_PROJECT;
|
||||
else Bun.env.GCLOUD_PROJECT = originalGcloudProject;
|
||||
if (originalVertexLocation === undefined) delete Bun.env.GOOGLE_VERTEX_LOCATION;
|
||||
else Bun.env.GOOGLE_VERTEX_LOCATION = originalVertexLocation;
|
||||
if (originalCloudLocation === undefined) delete Bun.env.GOOGLE_CLOUD_LOCATION;
|
||||
else Bun.env.GOOGLE_CLOUD_LOCATION = originalCloudLocation;
|
||||
if (originalLocation === undefined) delete Bun.env.VERTEX_LOCATION;
|
||||
else Bun.env.VERTEX_LOCATION = originalLocation;
|
||||
if (originalApiKey === undefined) delete Bun.env.GOOGLE_CLOUD_API_KEY;
|
||||
else Bun.env.GOOGLE_CLOUD_API_KEY = originalApiKey;
|
||||
if (originalGac === undefined) delete Bun.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
else Bun.env.GOOGLE_APPLICATION_CREDENTIALS = originalGac;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Google Vertex Provider (gemini-3-flash-preview)", () => {
|
||||
|
||||
@@ -5,6 +5,22 @@
|
||||
|
||||
- Added isolated profile support via `--profile <name>` / `OMP_PROFILE` and shell alias bootstrap via `--alias <command>`, including launch/ACP bootstrap handling, extension-flag-safe parsing, profile-scoped user config discovery, and symlinked extension-directory discovery.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the working spinner appearing to ignore Esc for 2-3 seconds when an interrupt lands mid-tool. Esc fires the abort synchronously, but the agent loop only stops the loader at `agent_end`, which it cannot reach until every in-flight tool settles in `executeToolCalls`' `await Promise.allSettled(...)` — and process/subagent/kernel-owning tools tear down gracefully (SIGTERM, 2-3s grace, SIGKILL), so the loader kept showing the unchanged "Working…/<intent>" line and read as a dropped keypress. The loader now switches to "Interrupting…" the instant Esc requests the abort and freezes intent-driven label updates until the turn unwinds (`EventController.notifyInterrupting`), so the interrupt is acknowledged immediately even while teardown completes.
|
||||
- Fixed a flaky JS eval worker startup that intermittently failed unrelated CI runs. The worker-ready wait reused Bun's 5s default per-test timeout as its floor, so a slow cold-start under `--isolate` + high concurrency was aborted mid-init; terminating a still-initializing Bun worker is the documented SIGILL/SIGTRAP crash trigger, which took down the whole test file. Worker init now floors at a fixed 15s infrastructure budget (independent of, and still dominated by, a larger per-cell `timeout`), and the JS eval test suites set a 20s file-local timeout so cold starts complete instead of being torn down.
|
||||
- Fixed reviewer-style subagent yields crashing the calling eval cell when a caller-supplied output schema declares `additionalProperties: false` without a `findings` property. `normalizeCompleteData` now consults the active validator before splicing collected `report_finding` entries onto the yielded payload, so injection is suppressed when the schema would reject it — keeping the executor's post-mortem validation in lockstep with the in-tool `yield` validation that already accepted the same raw payload ([#2070](https://github.com/can1357/oh-my-pi/issues/2070))
|
||||
- Fixed Anthropic empty `toolUse` stops without tool calls corrupting session history by retrying them and removing orphaned turns even at the retry cap.
|
||||
- Fixed MCP tools hanging in non-yolo modes by declaring `approval = "write"` on `MCPTool` and `DeferredMCPTool`, and propagating the `approval` property through `customToolToDefinition()` in `sdk.ts`
|
||||
- Fixed session resumption after a working directory is moved/renamed (e.g. `git worktree move`): `--continue` now re-roots the terminal's last session into the new directory when its original directory no longer exists, instead of silently starting a fresh empty session; cross-project `--resume <id>` offers to move (re-root) the session rather than only forking a duplicate copy when the source directory is gone
|
||||
- Fixed Kitty OSC 5522 paste rejecting plain text as "no supported text or image data": the listing parser now decodes the `mime="."` DATA payload (whitespace-separated MIME list) Kitty actually sends, in addition to the per-type DATA packets described by the ancillary 5522-mode spec ([#2051](https://github.com/can1357/oh-my-pi/issues/2051))
|
||||
- Fixed follow-up shortcut submission of builtin slash commands so `/goal set ...` applies goal mode instead of queueing as plain text.
|
||||
- Fixed Ctrl+Z crashing the agent on Windows with `TypeError: Unknown signal: SIGTSTP`. `InputController.handleCtrlZ` called `process.kill(0, "SIGTSTP")` unconditionally, but `SIGTSTP` is POSIX job-control and Bun/Node on Windows rejects the signal name from the JS side; the throw propagated out of the TUI input dispatcher as an uncaught exception. The handler now no-ops with a "Suspend (Ctrl+Z) is not supported on this platform" status on Windows, and on POSIX wraps `process.kill` in a try/catch that detaches the registered SIGCONT resume hook and re-`start()`s the TUI on failure so a rejected signal can never leave the UI stranded with a leaked listener ([#2036](https://github.com/can1357/oh-my-pi/issues/2036)).
|
||||
- Fixed a relative `--cwd` target (e.g. `omp --cwd repo` launched from `/tmp`) leaking the raw relative string into the session config. `applyStartupCwd` chdired into the resolved directory via `setProjectDir` but left `parsed.cwd` as `"repo"`, so `buildSessionOptions` (which prefers `parsed.cwd` over `getProjectDir()`) handed downstream settings/discovery/session creation a value that re-resolved against the new process cwd (`/tmp/repo/repo`) or persisted a relative session cwd. `parsed.cwd` is now re-synced to the resolved absolute project dir after the chdir.
|
||||
- Fixed the `--cwd` launch flag so it is parsed and can override the startup directory instead of always falling back to the current process directory or home auto-switch target.
|
||||
- Fixed session auto-retry for generic `upstream_error: Upstream request failed` gateway failures.
|
||||
- Stripped read-output line-number prefixes (`N:`) from auto-piped bare body rows in the hashline edit parser, so pasting `3:text` without a `+` prefix no longer injects `3:` as literal content. Uses single-pass stripping to avoid corrupting content whose own text starts with `digits:` ([#1492](https://github.com/can1357/oh-my-pi/issues/1492)).
|
||||
|
||||
## [15.10.1] - 2026-06-07
|
||||
|
||||
### Added
|
||||
@@ -9573,4 +9589,4 @@ Initial public release.
|
||||
- Git branch display in footer
|
||||
- Message queueing during streaming responses
|
||||
- OAuth integration for Gmail and Google Calendar access
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
- HTML export with syntax highlighting and collapsible sections
|
||||
|
||||
@@ -225,7 +225,7 @@ export function getExtraHelpText(): string {
|
||||
NODE_EXTRA_CA_CERTS - CA bundle path (or inline PEM) for server certificate validation
|
||||
OPENAI_API_KEY - OpenAI GPT models
|
||||
GEMINI_API_KEY - Google Gemini models
|
||||
GITHUB_TOKEN - GitHub Copilot (or GH_TOKEN, COPILOT_GITHUB_TOKEN)
|
||||
COPILOT_GITHUB_TOKEN - GitHub Copilot
|
||||
|
||||
${chalk.dim("# Additional LLM Providers")}
|
||||
AZURE_OPENAI_API_KEY - Azure OpenAI models
|
||||
|
||||
@@ -94,6 +94,9 @@ const setResume: OptionalSetter = (result, value) => {
|
||||
* token starts with `-`.
|
||||
*/
|
||||
export const STRING_SETTERS: Record<string, StringSetter> = {
|
||||
"--cwd": (result, value) => {
|
||||
result.cwd = value;
|
||||
},
|
||||
"--mode": (result, value) => {
|
||||
if (value === "text" || value === "json" || value === "rpc" || value === "acp" || value === "rpc-ui") {
|
||||
result.mode = value;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getProjectDir, normalizePathForComparison, setProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import type { Args } from "./args";
|
||||
|
||||
async function maybeAutoChdir(parsed: Args): Promise<void> {
|
||||
if (parsed.allowHome || parsed.cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
if (!home) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizePath = normalizePathForComparison;
|
||||
|
||||
const cwd = normalizePath(getProjectDir());
|
||||
const normalizedHome = normalizePath(home);
|
||||
if (cwd !== normalizedHome) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDirectory = async (p: string) => {
|
||||
try {
|
||||
const s = await fs.stat(p);
|
||||
return s.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const candidates = [path.join(home, "tmp"), "/tmp", "/var/tmp"];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (!(await isDirectory(candidate))) {
|
||||
continue;
|
||||
}
|
||||
setProjectDir(candidate);
|
||||
return;
|
||||
} catch {
|
||||
// Try next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fallback = os.tmpdir();
|
||||
if (fallback && normalizePath(fallback) !== cwd && (await isDirectory(fallback))) {
|
||||
setProjectDir(fallback);
|
||||
}
|
||||
} catch {
|
||||
// Ignore fallback errors.
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyStartupCwd(parsed: Args): Promise<void> {
|
||||
if (parsed.cwd) {
|
||||
setProjectDir(parsed.cwd);
|
||||
// setProjectDir resolves the (possibly relative) target against the launch
|
||||
// cwd and chdirs into it. Re-sync parsed.cwd to the resolved absolute path
|
||||
// so downstream consumers (buildSessionOptions, settings/discovery, session
|
||||
// persistence) don't re-resolve a relative string against the new cwd.
|
||||
parsed.cwd = getProjectDir();
|
||||
return;
|
||||
}
|
||||
await maybeAutoChdir(parsed);
|
||||
}
|
||||
@@ -55,6 +55,9 @@ export default class Index extends Command {
|
||||
alias: Flags.string({
|
||||
description: "Create a shell shortcut for the selected profile and exit",
|
||||
}),
|
||||
cwd: Flags.string({
|
||||
description: "Directory to start in (overrides the launch cwd)",
|
||||
}),
|
||||
mode: Flags.string({
|
||||
description: "Output mode: text (default), json, rpc, or rpc-ui",
|
||||
options: ["text", "json", "rpc", "acp", "rpc-ui"],
|
||||
|
||||
@@ -53,7 +53,13 @@ interface JsSession {
|
||||
const sessions = new Map<string, JsSession>();
|
||||
const startingSessions = new Map<string, Promise<JsSession>>();
|
||||
const resettingSessions = new Set<string>();
|
||||
const READY_TIMEOUT_MS_DEFAULT = 5_000;
|
||||
// Worker startup (module-graph import + WorkerCore construction) is infrastructure
|
||||
// cost, not user compute. Floor it independently of Bun's 5s default per-test timeout
|
||||
// so a slow cold-start under load isn't aborted mid-init — terminating a still-
|
||||
// initializing Bun worker triggers the same kind of terminate-race that motivates
|
||||
// avoiding `vm.runInContext` (see shared/indirect-eval.ts), here surfacing as a
|
||||
// SIGILL/SIGSEGV. Callers that pass a larger per-cell budget still dominate.
|
||||
const WORKER_INIT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export async function executeInVmContext(options: {
|
||||
sessionKey: string;
|
||||
@@ -191,9 +197,9 @@ async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, tim
|
||||
handleSessionMessage(session, msg);
|
||||
});
|
||||
try {
|
||||
// Cold-start can exceed 5s on slow hosts. Let the caller's per-cell timeout dominate so
|
||||
// users can grant more headroom when they raise `timeout` on a cell.
|
||||
const readyTimeoutMs = Math.max(READY_TIMEOUT_MS_DEFAULT, timeoutMs ?? 0);
|
||||
// Init headroom is the fixed infrastructure floor; the caller's per-cell timeout
|
||||
// dominates when larger so users can grant more by raising `timeout` on a cell.
|
||||
const readyTimeoutMs = Math.max(WORKER_INIT_TIMEOUT_MS, timeoutMs ?? 0);
|
||||
await raceWithTimeout(readyPromise, readyTimeoutMs, "Timed out initializing JS eval worker");
|
||||
worker.send({ type: "init", snapshot });
|
||||
sessions.set(sessionKey, session);
|
||||
|
||||
@@ -7,7 +7,13 @@
|
||||
* - Register commands, keyboard shortcuts, and CLI flags
|
||||
* - Interact with the user via UI primitives
|
||||
*/
|
||||
import type { AgentMessage, AgentToolResult, AgentToolUpdateCallback, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
||||
import type {
|
||||
AgentMessage,
|
||||
AgentToolResult,
|
||||
AgentToolUpdateCallback,
|
||||
ThinkingLevel,
|
||||
ToolApproval,
|
||||
} from "@oh-my-pi/pi-agent-core";
|
||||
import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
|
||||
import type {
|
||||
Api,
|
||||
@@ -392,6 +398,9 @@ export interface ToolDefinition<TParams extends TSchema = TSchema, TDetails = un
|
||||
defaultInactive?: boolean;
|
||||
/** If true, tool may stage deferred changes that require explicit resolve/discard. */
|
||||
deferrable?: boolean;
|
||||
/** Tool approval tier. Defaults to `"exec"` when omitted.
|
||||
* `"read"`: read-only operations. `"write"`: mutations. `"exec"`: code execution. */
|
||||
approval?: ToolApproval;
|
||||
/** MCP server name for discovery/search metadata when this tool fronts an MCP server. */
|
||||
mcpServerName?: string;
|
||||
/** Original MCP tool name for discovery/search metadata. */
|
||||
|
||||
@@ -4,10 +4,8 @@
|
||||
* This file handles CLI argument parsing and translates them into
|
||||
* createAgentSession() options. The SDK does the heavy lifting.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as fsSync from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { EventLoopKeepalive } from "@oh-my-pi/pi-agent-core";
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
@@ -28,6 +26,7 @@ import { processFileArguments } from "./cli/file-processor";
|
||||
import { buildInitialMessage } from "./cli/initial-message";
|
||||
import { runListModelsCommand } from "./cli/list-models";
|
||||
import { selectSession } from "./cli/session-picker";
|
||||
import { applyStartupCwd } from "./cli/startup-cwd";
|
||||
import { findConfigFile } from "./config";
|
||||
import { ModelRegistry, ModelsConfigFile } from "./config/model-registry";
|
||||
import { resolveCliModel, resolveModelRoleValue, resolveModelScope, type ScopedModel } from "./config/model-resolver";
|
||||
@@ -344,11 +343,11 @@ async function runInteractiveMode(
|
||||
}
|
||||
}
|
||||
|
||||
type ForkSessionPromptResult = "accepted" | "declined" | "unavailable";
|
||||
type SessionPromptResult = "accepted" | "declined" | "unavailable";
|
||||
|
||||
type ForkSessionPrompt = (session: SessionInfo) => Promise<ForkSessionPromptResult>;
|
||||
type SessionPrompt = (session: SessionInfo) => Promise<SessionPromptResult>;
|
||||
|
||||
async function promptForkSession(session: SessionInfo): Promise<ForkSessionPromptResult> {
|
||||
async function promptForkSession(session: SessionInfo): Promise<SessionPromptResult> {
|
||||
if (!process.stdin.isTTY) {
|
||||
return "unavailable";
|
||||
}
|
||||
@@ -362,6 +361,20 @@ async function promptForkSession(session: SessionInfo): Promise<ForkSessionPromp
|
||||
}
|
||||
}
|
||||
|
||||
async function promptMoveSession(session: SessionInfo): Promise<SessionPromptResult> {
|
||||
if (!process.stdin.isTTY) {
|
||||
return "unavailable";
|
||||
}
|
||||
const message = `Session's directory no longer exists (${session.cwd}). Move (re-root) it into the current directory? [Y/n] `;
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
const answer = (await rl.question(message)).trim().toLowerCase();
|
||||
return answer === "" || answer === "y" || answer === "yes" ? "accepted" : "declined";
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function getChangelogForDisplay(parsed: Args): Promise<string | undefined> {
|
||||
if (parsed.continue || parsed.resume) {
|
||||
return undefined;
|
||||
@@ -407,7 +420,8 @@ export async function createSessionManager(
|
||||
parsed: Args,
|
||||
cwd: string,
|
||||
activeSettings: Settings = settings,
|
||||
askToForkSession: ForkSessionPrompt = promptForkSession,
|
||||
askToForkSession: SessionPrompt = promptForkSession,
|
||||
askToMoveSession: SessionPrompt = promptMoveSession,
|
||||
): Promise<SessionManager | undefined> {
|
||||
if (parsed.fork) {
|
||||
if (parsed.noSession) {
|
||||
@@ -440,6 +454,24 @@ export async function createSessionManager(
|
||||
const normalizedCwd = normalizePathForComparison(cwd);
|
||||
const normalizedMatchCwd = normalizePathForComparison(match.session.cwd || cwd);
|
||||
if (normalizedCwd !== normalizedMatchCwd) {
|
||||
// If the session's recorded directory no longer exists, it was almost
|
||||
// certainly moved/renamed (e.g. `git worktree move`). Re-root the existing
|
||||
// session here instead of forking a duplicate copy.
|
||||
const sourceCwd = match.session.cwd;
|
||||
if (sourceCwd && !fsSync.existsSync(sourceCwd)) {
|
||||
const movePromptResult = await askToMoveSession(match.session);
|
||||
if (movePromptResult === "unavailable") {
|
||||
throw new Error(
|
||||
`Session "${sessionArg}" belongs to a directory that no longer exists (${sourceCwd}); run interactively to move it into the current project.`,
|
||||
);
|
||||
}
|
||||
if (movePromptResult === "declined") {
|
||||
return undefined;
|
||||
}
|
||||
const manager = await SessionManager.open(match.session.path, parsed.sessionDir);
|
||||
await manager.moveTo(cwd, parsed.sessionDir);
|
||||
return manager;
|
||||
}
|
||||
const forkPromptResult = await askToForkSession(match.session);
|
||||
if (forkPromptResult === "unavailable") {
|
||||
throw new Error(
|
||||
@@ -480,56 +512,6 @@ export async function createSessionManager(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function maybeAutoChdir(parsed: Args): Promise<void> {
|
||||
if (parsed.allowHome || parsed.cwd) {
|
||||
return;
|
||||
}
|
||||
|
||||
const home = os.homedir();
|
||||
if (!home) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizePath = normalizePathForComparison;
|
||||
|
||||
const cwd = normalizePath(getProjectDir());
|
||||
const normalizedHome = normalizePath(home);
|
||||
if (cwd !== normalizedHome) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isDirectory = async (p: string) => {
|
||||
try {
|
||||
const s = await fs.stat(p);
|
||||
return s.isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const candidates = [path.join(home, "tmp"), "/tmp", "/var/tmp"];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (!(await isDirectory(candidate))) {
|
||||
continue;
|
||||
}
|
||||
setProjectDir(candidate);
|
||||
return;
|
||||
} catch {
|
||||
// Try next candidate.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fallback = os.tmpdir();
|
||||
if (fallback && normalizePath(fallback) !== cwd && (await isDirectory(fallback))) {
|
||||
setProjectDir(fallback);
|
||||
}
|
||||
} catch {
|
||||
// Ignore fallback errors.
|
||||
}
|
||||
}
|
||||
|
||||
/** Discover SYSTEM.md file if no CLI system prompt was provided */
|
||||
function discoverSystemPromptFile(): string | undefined {
|
||||
// Check project-local first (.omp/SYSTEM.md, .pi/SYSTEM.md legacy)
|
||||
@@ -745,7 +727,7 @@ export async function runRootCommand(
|
||||
await logger.time("initTheme:initial", initTheme);
|
||||
|
||||
const parsedArgs = parsed;
|
||||
await logger.time("maybeAutoChdir", maybeAutoChdir, parsedArgs);
|
||||
await logger.time("applyStartupCwd", applyStartupCwd, parsedArgs);
|
||||
|
||||
const notifs: (InteractiveModeNotify | null)[] = [];
|
||||
|
||||
|
||||
@@ -220,6 +220,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
|
||||
readonly mcpToolName: string;
|
||||
/** Server name */
|
||||
readonly mcpServerName: string;
|
||||
readonly approval = "write" as const;
|
||||
/** Render completed MCP calls with the result header replacing the pending call header. */
|
||||
readonly mergeCallAndResult = true;
|
||||
|
||||
@@ -305,6 +306,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
|
||||
readonly mcpToolName: string;
|
||||
/** Server name */
|
||||
readonly mcpServerName: string;
|
||||
readonly approval = "write" as const;
|
||||
/** Render completed MCP calls with the result header replacing the pending call header. */
|
||||
readonly mergeCallAndResult = true;
|
||||
|
||||
|
||||
@@ -26,6 +26,16 @@ type AgentSessionEventKind = AgentSessionEvent["type"];
|
||||
|
||||
const IRC_MESSAGE_VISIBLE_TTL_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Loader label shown the instant a user interrupt (Esc) is requested, kept until
|
||||
* the agent turn fully unwinds. Esc fires the abort synchronously, but the loop
|
||||
* only stops the spinner at `agent_end`, which it cannot reach until every
|
||||
* in-flight tool settles its abort in `executeToolCalls` (`Promise.allSettled`).
|
||||
* Swapping the steady "Working…" for this acknowledges the keypress instead of
|
||||
* reading as an ignored Esc for the seconds a slow tool takes to tear down.
|
||||
*/
|
||||
export const INTERRUPTING_WORKING_MESSAGE = "Interrupting…";
|
||||
|
||||
// Events that change foreground streaming state, or that reset a turn. The TUI
|
||||
// eager native-scrollback rebuild mode is recomputed only on these so unrelated
|
||||
// IRC/notices/status refreshes do not toggle scrollback replay policy.
|
||||
@@ -57,6 +67,7 @@ export class EventController {
|
||||
#backgroundToolCallIds = new Set<string>();
|
||||
#assistantMessageStreaming = false;
|
||||
#agentTurnActive = false;
|
||||
#interrupting = false;
|
||||
#readToolCallArgs = new Map<string, Record<string, unknown>>();
|
||||
#readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
|
||||
#lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
|
||||
@@ -167,6 +178,7 @@ export class EventController {
|
||||
return true;
|
||||
}
|
||||
#updateWorkingMessageFromIntent(intent: unknown): void {
|
||||
if (this.#interrupting) return;
|
||||
// Streamed JSON can deliver non-string `_i` (object, number, boolean) before
|
||||
// schema validation; `?.` only guards null/undefined, so guard the type too.
|
||||
if (typeof intent !== "string") return;
|
||||
@@ -176,6 +188,19 @@ export class EventController {
|
||||
this.ctx.setWorkingMessage(`${trimmed}${interruptHint()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge a user interrupt (Esc) immediately: switch the loader to
|
||||
* `INTERRUPTING_WORKING_MESSAGE` and freeze intent-driven working-message
|
||||
* updates for the rest of the turn so a late `tool_execution_start` intent
|
||||
* cannot repaint a "Working…/<intent>" line over the acknowledgment. Reset at
|
||||
* the next `agent_start`. No-op outside an active turn or if already set.
|
||||
*/
|
||||
notifyInterrupting(): void {
|
||||
if (!this.#agentTurnActive || this.#interrupting) return;
|
||||
this.#interrupting = true;
|
||||
this.ctx.setWorkingMessage(INTERRUPTING_WORKING_MESSAGE);
|
||||
}
|
||||
|
||||
subscribeToAgent(): void {
|
||||
this.ctx.unsubscribe = this.ctx.session.subscribe(async (event: AgentSessionEvent) => {
|
||||
await this.handleEvent(event);
|
||||
@@ -220,6 +245,7 @@ export class EventController {
|
||||
|
||||
async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
|
||||
this.#agentTurnActive = true;
|
||||
this.#interrupting = false;
|
||||
this.#lastIntent = undefined;
|
||||
this.#readToolCallArgs.clear();
|
||||
this.#readToolCallAssistantComponents.clear();
|
||||
|
||||
@@ -94,6 +94,7 @@ export class InputController {
|
||||
if (this.ctx.loopModeEnabled) {
|
||||
this.ctx.pauseLoop();
|
||||
if (this.ctx.session.isStreaming) {
|
||||
this.ctx.notifyInterrupting();
|
||||
void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
|
||||
} else {
|
||||
this.ctx.cancelPendingSubmission();
|
||||
@@ -124,6 +125,7 @@ export class InputController {
|
||||
this.ctx.isPythonMode = false;
|
||||
this.ctx.updateEditorBorderColor();
|
||||
} else if (this.ctx.session.isStreaming) {
|
||||
this.ctx.notifyInterrupting();
|
||||
void this.ctx.session.abort({ reason: USER_INTERRUPT_LABEL });
|
||||
} else if (!this.ctx.editor.getText().trim()) {
|
||||
// Double-interrupt with empty editor triggers /tree, /branch, or nothing based on setting
|
||||
@@ -499,17 +501,44 @@ export class InputController {
|
||||
}
|
||||
|
||||
handleCtrlZ(): void {
|
||||
// Set up handler to restore TUI when resumed
|
||||
process.once("SIGCONT", () => {
|
||||
// SIGTSTP is POSIX job-control: Windows has no equivalent and
|
||||
// `process.kill(_, "SIGTSTP")` throws `TypeError: Unknown signal:
|
||||
// SIGTSTP` there, taking the whole agent down via an uncaught
|
||||
// exception (issue #2036). No-op on platforms that cannot suspend.
|
||||
if (process.platform === "win32") {
|
||||
this.ctx.showStatus("Suspend (Ctrl+Z) is not supported on this platform");
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture the listener so we can detach it if the signal never
|
||||
// fires; otherwise a failed suspend would leave a stale SIGCONT
|
||||
// handler that fires on the next unrelated continue and tries to
|
||||
// re-`start()` an already-running TUI.
|
||||
const onResume = (): void => {
|
||||
this.ctx.ui.start();
|
||||
this.ctx.ui.requestRender(true);
|
||||
});
|
||||
};
|
||||
process.once("SIGCONT", onResume);
|
||||
|
||||
// Stop the TUI (restore terminal to normal mode)
|
||||
// Stop the TUI (restore terminal to normal mode) before sending the
|
||||
// signal so the parent shell sees a sane terminal state.
|
||||
this.ctx.ui.stop();
|
||||
|
||||
// Send SIGTSTP to process group (pid=0 means all processes in group)
|
||||
process.kill(0, "SIGTSTP");
|
||||
try {
|
||||
// pid=0 → entire foreground process group; the shell receives
|
||||
// SIGTSTP and parks the job.
|
||||
process.kill(0, "SIGTSTP");
|
||||
} catch (err) {
|
||||
// Either the runtime refused the signal or the kernel rejected
|
||||
// it (some sandboxes block sending to pid=0). Tear the resume
|
||||
// hook down and bring the TUI back so the user is not stranded
|
||||
// on a frozen prompt.
|
||||
process.removeListener("SIGCONT", onResume);
|
||||
this.ctx.ui.start();
|
||||
this.ctx.ui.requestRender(true);
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
this.ctx.showError(`Failed to suspend: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
handleDequeue(): void {
|
||||
@@ -589,7 +618,7 @@ export class InputController {
|
||||
|
||||
/** Send editor text as a follow-up message (queued behind current stream). */
|
||||
async handleFollowUp(): Promise<void> {
|
||||
const text = this.ctx.editor.getText().trim();
|
||||
let text = this.ctx.editor.getText().trim();
|
||||
if (!text) return;
|
||||
|
||||
// Compaction first: while compacting, free text gets queued via
|
||||
@@ -603,6 +632,16 @@ export class InputController {
|
||||
return;
|
||||
}
|
||||
|
||||
const slashResult = await executeBuiltinSlashCommand(text, {
|
||||
ctx: this.ctx,
|
||||
});
|
||||
if (slashResult === true) {
|
||||
return;
|
||||
}
|
||||
if (typeof slashResult === "string") {
|
||||
text = slashResult;
|
||||
}
|
||||
|
||||
// Skill commands invoke through the custom-message path regardless of
|
||||
// which keybinding submitted them. Enter routes them as `steer`;
|
||||
// Ctrl+Enter (this handler) routes them as `followUp`.
|
||||
|
||||
@@ -2707,6 +2707,10 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
this.setWorkingMessage(message);
|
||||
}
|
||||
|
||||
notifyInterrupting(): void {
|
||||
this.#eventController.notifyInterrupting();
|
||||
}
|
||||
|
||||
showNewVersionNotification(newVersion: string): void {
|
||||
this.#uiHelpers.showNewVersionNotification(newVersion);
|
||||
}
|
||||
|
||||
@@ -182,6 +182,9 @@ export interface InteractiveModeContext {
|
||||
flushPendingModelSwitch(): Promise<void>;
|
||||
setWorkingMessage(message?: string): void;
|
||||
applyPendingWorkingMessage(): void;
|
||||
/** Acknowledge a user interrupt (Esc) by switching the loader to an
|
||||
* "Interrupting…" label until the agent turn unwinds. */
|
||||
notifyInterrupting(): void;
|
||||
ensureLoadingAnimation(): void;
|
||||
startPendingSubmission(input: {
|
||||
text: string;
|
||||
|
||||
@@ -709,6 +709,7 @@ function customToolToDefinition(tool: CustomTool): ToolDefinition {
|
||||
parameters: tool.parameters,
|
||||
hidden: tool.hidden,
|
||||
deferrable: tool.deferrable,
|
||||
approval: typeof tool.approval === "function" ? tool.approval.bind(tool) : tool.approval,
|
||||
mcpServerName: tool.mcpServerName,
|
||||
mcpToolName: tool.mcpToolName,
|
||||
execute: (toolCallId, params, signal, onUpdate, ctx) =>
|
||||
|
||||
@@ -283,6 +283,11 @@ export type AgentSessionEventListener = (event: AgentSessionEvent) => void;
|
||||
export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "label" | "startTime">;
|
||||
|
||||
const EMPTY_STOP_MAX_RETRIES = 3;
|
||||
const NON_WHITESPACE_RE = /\S/;
|
||||
|
||||
function hasNonWhitespace(value: string): boolean {
|
||||
return NON_WHITESPACE_RE.test(value);
|
||||
}
|
||||
|
||||
export interface AsyncJobSnapshot {
|
||||
running: AsyncJobSnapshotItem[];
|
||||
@@ -6539,9 +6544,13 @@ export class AgentSession {
|
||||
this.#retryAttempt = 0;
|
||||
}
|
||||
this.#resolveRetry();
|
||||
// Tool-use orphans corrupt Anthropic message history (tool_result without
|
||||
// matching tool_use). Always remove them even when the retry cap is hit.
|
||||
if (assistantMessage.stopReason === "toolUse") {
|
||||
this.#removeEmptyStopFromActiveContext(assistantMessage);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
this.#removeEmptyStopFromActiveContext(assistantMessage);
|
||||
this.agent.appendMessage({
|
||||
role: "developer",
|
||||
@@ -6554,12 +6563,26 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
#isEmptyAssistantStop(assistantMessage: AssistantMessage): boolean {
|
||||
if (assistantMessage.stopReason !== "stop") return false;
|
||||
return !assistantMessage.content.some(content => {
|
||||
if (content.type === "text") return content.text.trim().length > 0;
|
||||
if (content.type === "thinking") return content.thinking.trim().length > 0;
|
||||
return content.type === "toolCall";
|
||||
});
|
||||
switch (assistantMessage.stopReason) {
|
||||
case "stop":
|
||||
for (const content of assistantMessage.content) {
|
||||
if (content.type === "toolCall") return false;
|
||||
if (content.type === "text" && hasNonWhitespace(content.text)) return false;
|
||||
if (content.type === "thinking" && hasNonWhitespace(content.thinking)) return false;
|
||||
}
|
||||
return true;
|
||||
case "toolUse":
|
||||
// An orphaned toolUse stop (no tool_use block) corrupts Anthropic history:
|
||||
// a later tool_result has nothing to anchor to. Thinking alone cannot anchor
|
||||
// a tool_result, so it does not rescue a toolUse stop here.
|
||||
for (const content of assistantMessage.content) {
|
||||
if (content.type === "toolCall") return false;
|
||||
if (content.type === "text" && hasNonWhitespace(content.text)) return false;
|
||||
}
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#emptyStopRetryReminder(): string {
|
||||
@@ -7874,11 +7897,12 @@ export class AgentSession {
|
||||
#isTransientTransportErrorMessage(errorMessage: string): boolean {
|
||||
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
|
||||
// service unavailable, provider-suggested retry, network/connection/socket errors, fetch failed,
|
||||
// terminated, retry delay exceeded, Bun HTTP/2 stream resets (RST_STREAM / REFUSED_STREAM /
|
||||
// ENHANCE_YOUR_CALM, surfaced verbatim from src/http/h2_client/dispatch.zig)
|
||||
// gateway upstream failures, terminated, retry delay exceeded, Bun HTTP/2 stream resets
|
||||
// (RST_STREAM / REFUSED_STREAM / ENHANCE_YOUR_CALM, surfaced verbatim from
|
||||
// src/http/h2_client/dispatch.zig)
|
||||
return (
|
||||
isUnexpectedSocketCloseMessage(errorMessage) ||
|
||||
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)/i.test(
|
||||
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)/i.test(
|
||||
errorMessage,
|
||||
)
|
||||
);
|
||||
|
||||
@@ -845,11 +845,18 @@ function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
|
||||
Bun.write(breadcrumbFile, content).catch(() => {});
|
||||
}
|
||||
|
||||
interface TerminalBreadcrumb {
|
||||
cwd: string;
|
||||
sessionFile: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the terminal breadcrumb for the current terminal, scoped to a cwd.
|
||||
* Returns the session file path if it exists and matches the cwd, null otherwise.
|
||||
* Read the raw terminal breadcrumb for the current terminal.
|
||||
* Returns the recorded cwd + session file (verified to exist) regardless of
|
||||
* whether the recorded cwd still matches the current one. Callers decide how
|
||||
* to interpret a cwd mismatch (e.g. a moved/renamed worktree).
|
||||
*/
|
||||
async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
|
||||
async function readTerminalBreadcrumbEntry(): Promise<TerminalBreadcrumb | null> {
|
||||
const terminalId = getTerminalId();
|
||||
if (!terminalId) return null;
|
||||
|
||||
@@ -862,12 +869,9 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
|
||||
const breadcrumbCwd = lines[0];
|
||||
const sessionFile = lines[1];
|
||||
|
||||
// Only return if cwd matches (user might have cd'd)
|
||||
if (path.resolve(breadcrumbCwd) !== path.resolve(cwd)) return null;
|
||||
|
||||
// Verify the session file still exists
|
||||
const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
|
||||
if (stat?.isFile()) return sessionFile;
|
||||
if (stat?.isFile()) return { cwd: breadcrumbCwd, sessionFile };
|
||||
} catch (err) {
|
||||
if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
|
||||
// Breadcrumb doesn't exist or is corrupt — fall through
|
||||
@@ -2163,19 +2167,24 @@ export class SessionManager {
|
||||
/**
|
||||
* Move the session to a new working directory.
|
||||
* Moves session files and artifacts on disk, updates all internal references,
|
||||
* and rewrites the session header with the new cwd.
|
||||
* and rewrites the session header with the new cwd. When provided,
|
||||
* `targetSessionDir` is used instead of deriving the default directory for
|
||||
* the new cwd (for `--continue --session-dir` / `--resume --session-dir`).
|
||||
*/
|
||||
async moveTo(newCwd: string): Promise<void> {
|
||||
async moveTo(newCwd: string, targetSessionDir?: string): Promise<void> {
|
||||
const resolvedCwd = path.resolve(newCwd);
|
||||
if (resolvedCwd === this.cwd) return;
|
||||
if (resolvedCwd === this.cwd && (!targetSessionDir || path.resolve(targetSessionDir) === this.sessionDir)) return;
|
||||
|
||||
const managedSessionsRoot = resolveManagedSessionRoot(this.sessionDir, this.cwd);
|
||||
const newSessionDir = managedSessionsRoot
|
||||
? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
|
||||
: computeDefaultSessionDir(resolvedCwd, this.storage);
|
||||
const newSessionDir = targetSessionDir
|
||||
? path.resolve(targetSessionDir)
|
||||
: managedSessionsRoot
|
||||
? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
|
||||
: computeDefaultSessionDir(resolvedCwd, this.storage);
|
||||
let hadSessionFile = false;
|
||||
|
||||
if (this.persist && this.#sessionFile) {
|
||||
this.storage.ensureDirSync(newSessionDir);
|
||||
// Close the persist writer before moving files
|
||||
await this.#closePersistWriter();
|
||||
this.#persistChain = Promise.resolve();
|
||||
@@ -2186,25 +2195,29 @@ export class SessionManager {
|
||||
const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
|
||||
const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
|
||||
const newArtifactDir = newSessionFile.slice(0, -6);
|
||||
const sameSessionFile = path.resolve(oldSessionFile) === path.resolve(newSessionFile);
|
||||
const sameArtifactDir = path.resolve(oldArtifactDir) === path.resolve(newArtifactDir);
|
||||
hadSessionFile = this.storage.existsSync(oldSessionFile);
|
||||
let movedSessionFile = false;
|
||||
let movedArtifactDir = false;
|
||||
|
||||
try {
|
||||
// Guard: session file may not exist yet (no assistant messages persisted)
|
||||
if (hadSessionFile) {
|
||||
if (hadSessionFile && !sameSessionFile) {
|
||||
await fs.promises.rename(oldSessionFile, newSessionFile);
|
||||
movedSessionFile = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(oldArtifactDir);
|
||||
if (stat.isDirectory()) {
|
||||
await fs.promises.rename(oldArtifactDir, newArtifactDir);
|
||||
movedArtifactDir = true;
|
||||
if (!sameArtifactDir) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(oldArtifactDir);
|
||||
if (stat.isDirectory()) {
|
||||
await fs.promises.rename(oldArtifactDir, newArtifactDir);
|
||||
movedArtifactDir = true;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isEnoent(err)) throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isEnoent(err)) throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
if (movedArtifactDir) {
|
||||
@@ -3491,8 +3504,49 @@ export class SessionManager {
|
||||
): Promise<SessionManager> {
|
||||
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
||||
// Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly)
|
||||
const terminalSession = await readTerminalBreadcrumb(cwd);
|
||||
const mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage));
|
||||
const breadcrumb = await readTerminalBreadcrumbEntry();
|
||||
const breadcrumbCwd = breadcrumb ? path.resolve(breadcrumb.cwd) : undefined;
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
let mostRecent: string | null | undefined;
|
||||
if (breadcrumb && breadcrumbCwd !== resolvedCwd) {
|
||||
// The terminal's last session was started in a different cwd. If that cwd no
|
||||
// longer exists (e.g. `git worktree move`/dir rename) and the new location has
|
||||
// no sessions of its own, re-root the session here instead of silently starting
|
||||
// fresh — otherwise the relocated session would be unreachable via --continue.
|
||||
// When an explicit sessionDir is reused across the move, the stale breadcrumb
|
||||
// file itself may be the most recent entry there; don't count it as a
|
||||
// current-directory session. If that shared dir also contains an older session
|
||||
// that already belongs to the current cwd, prefer that local session instead
|
||||
// of re-rooting the stale breadcrumb over it.
|
||||
const resolvedBreadcrumbCwd = path.resolve(breadcrumb.cwd);
|
||||
mostRecent = await findMostRecentSession(dir, storage);
|
||||
const sourceCwdGone = !fs.existsSync(resolvedBreadcrumbCwd);
|
||||
const breadcrumbSessionFile = path.resolve(breadcrumb.sessionFile);
|
||||
const mostRecentIsBreadcrumb =
|
||||
mostRecent !== null && mostRecent !== undefined && path.resolve(mostRecent) === breadcrumbSessionFile;
|
||||
let hasCurrentCwdSession = false;
|
||||
if (sourceCwdGone && mostRecentIsBreadcrumb) {
|
||||
const currentCwdSession = (await SessionManager.list(cwd, dir, storage)).find(
|
||||
session =>
|
||||
path.resolve(session.path) !== breadcrumbSessionFile &&
|
||||
session.cwd &&
|
||||
path.resolve(session.cwd) === resolvedCwd,
|
||||
);
|
||||
if (currentCwdSession) {
|
||||
mostRecent = currentCwdSession.path;
|
||||
hasCurrentCwdSession = true;
|
||||
}
|
||||
}
|
||||
const relocated = sourceCwdGone && (mostRecent === null || (mostRecentIsBreadcrumb && !hasCurrentCwdSession));
|
||||
if (relocated) {
|
||||
process.stderr.write(`Re-rooting moved session from ${resolvedBreadcrumbCwd} to ${resolvedCwd}.\n`);
|
||||
const manager = await SessionManager.open(breadcrumb.sessionFile, undefined, storage);
|
||||
await manager.moveTo(cwd, sessionDir);
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
const terminalSession = breadcrumb && breadcrumbCwd === resolvedCwd ? breadcrumb.sessionFile : null;
|
||||
if (mostRecent === undefined) mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage));
|
||||
const manager = new SessionManager(cwd, dir, true, storage);
|
||||
if (mostRecent) {
|
||||
await manager.#initSessionFile(mostRecent);
|
||||
|
||||
@@ -34,7 +34,11 @@ import { SessionManager } from "../session/session-manager";
|
||||
import { truncateTail } from "../session/streaming-output";
|
||||
import type { ContextFileEntry } from "../tools";
|
||||
import { normalizeSchema } from "../tools/jtd-to-json-schema";
|
||||
import { buildOutputValidator, summarizeValidationFailure } from "../tools/output-schema-validator";
|
||||
import {
|
||||
buildOutputValidator,
|
||||
type OutputValidator,
|
||||
summarizeValidationFailure,
|
||||
} from "../tools/output-schema-validator";
|
||||
|
||||
import { type ReportFindingDetails, toReviewFinding } from "../tools/review";
|
||||
import { ToolAbortError } from "../tools/tool-errors";
|
||||
@@ -256,21 +260,40 @@ function extractCompletionData(parsed: unknown): unknown {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeCompleteData(data: unknown, reportFindings?: ReviewFinding[]): unknown {
|
||||
let normalized = parseStringifiedJson(data ?? null);
|
||||
/**
|
||||
* Resolve the final yielded payload, optionally splicing collected
|
||||
* `report_finding` entries into a top-level `findings` array.
|
||||
*
|
||||
* Injection is suppressed when an active validator would reject the augmented
|
||||
* payload (e.g. a caller-supplied schema with `additionalProperties: false`
|
||||
* that does not declare `findings`). That keeps the in-tool yield validator
|
||||
* (which only sees the raw, pre-injection data) in lockstep with this
|
||||
* post-mortem validator — honoring the "accepted in-tool ⇒ accepted
|
||||
* post-mortem" guarantee documented in `output-schema-validator.ts`. The
|
||||
* dropped findings are still preserved verbatim in the agent's progress
|
||||
* stream and JSONL artifact, so no information is lost when injection is
|
||||
* suppressed.
|
||||
*/
|
||||
function normalizeCompleteData(
|
||||
data: unknown,
|
||||
reportFindings: ReviewFinding[] | undefined,
|
||||
validator: OutputValidator | undefined,
|
||||
): unknown {
|
||||
const normalized = parseStringifiedJson(data ?? null);
|
||||
if (
|
||||
Array.isArray(reportFindings) &&
|
||||
reportFindings.length > 0 &&
|
||||
normalized &&
|
||||
typeof normalized === "object" &&
|
||||
!Array.isArray(normalized)
|
||||
!Array.isArray(reportFindings) ||
|
||||
reportFindings.length === 0 ||
|
||||
!normalized ||
|
||||
typeof normalized !== "object" ||
|
||||
Array.isArray(normalized)
|
||||
) {
|
||||
const record = normalized as Record<string, unknown>;
|
||||
if (!("findings" in record)) {
|
||||
normalized = { ...record, findings: reportFindings };
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
return normalized;
|
||||
const record = normalized as Record<string, unknown>;
|
||||
if ("findings" in record) return normalized;
|
||||
const injected = { ...record, findings: reportFindings };
|
||||
if (validator && !validator.validate(injected).success) return normalized;
|
||||
return injected;
|
||||
}
|
||||
|
||||
function resolveFallbackCompletion(rawOutput: string, outputSchema: unknown): { data: unknown } | null {
|
||||
@@ -360,13 +383,13 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
|
||||
if (submitData === null || submitData === undefined) {
|
||||
rawOutput = rawOutput ? `${SUBAGENT_WARNING_NULL_YIELD}\n\n${rawOutput}` : SUBAGENT_WARNING_NULL_YIELD;
|
||||
} else {
|
||||
const completeData = normalizeCompleteData(submitData, reportFindings);
|
||||
const { validator, error: schemaError } = buildOutputValidator(outputSchema);
|
||||
if (schemaError) {
|
||||
rawOutput = `{"error":"schema_violation","message":"invalid output schema: ${schemaError.replace(/"/g, '\\"')}"}`;
|
||||
stderr = `schema_violation: invalid output schema: ${schemaError}`;
|
||||
exitCode = 1;
|
||||
} else {
|
||||
const completeData = normalizeCompleteData(submitData, reportFindings, validator);
|
||||
const result = validator?.validate(completeData) ?? { success: true as const };
|
||||
if (!result.success) {
|
||||
const summary = summarizeValidationFailure(result, completeData, validator?.requiredFields ?? []);
|
||||
@@ -393,8 +416,8 @@ export function finalizeSubprocessOutput(args: FinalizeSubprocessOutputArgs): Fi
|
||||
const hasOutputSchema = normalizedSchema !== undefined && !schemaError;
|
||||
const fallback = allowFallback ? resolveFallbackCompletion(rawOutput, outputSchema) : null;
|
||||
if (fallback) {
|
||||
const completeData = normalizeCompleteData(fallback.data, reportFindings);
|
||||
const { validator } = buildOutputValidator(outputSchema);
|
||||
const completeData = normalizeCompleteData(fallback.data, reportFindings, validator);
|
||||
const result = validator?.validate(completeData) ?? { success: true as const };
|
||||
if (!result.success) {
|
||||
const summary = summarizeValidationFailure(result, completeData, validator?.requiredFields ?? []);
|
||||
|
||||
@@ -7,6 +7,8 @@ const PASTE_EVENT_NAME_BASE64 = Buffer.from("Paste event", "utf8").toString("bas
|
||||
|
||||
const IMAGE_MIME_PRIORITY = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const;
|
||||
const TEXT_MIME_TYPE = "text/plain";
|
||||
/** Kitty's "give me the list of available MIME types" sentinel — see `TARGETS_MIME` in `kitty/clipboard.py`. */
|
||||
const MIME_LISTING_TARGET = ".";
|
||||
|
||||
type PasteReadKind = "image" | "text";
|
||||
|
||||
@@ -144,6 +146,24 @@ export class EnhancedPasteController {
|
||||
if (!mimeType) return;
|
||||
|
||||
if (state.phase === "listing") {
|
||||
// Kitty (as of writing) implements the "list available MIME types"
|
||||
// response shape by sending a single DATA packet with `mime="."` and
|
||||
// the available types packed into the payload as a whitespace-
|
||||
// separated list (see `fulfill_read_request` in
|
||||
// kovidgoyal/kitty:kitty/clipboard.py). The 5522-mode ancillary
|
||||
// spec instead encodes each type as its own DATA packet with an
|
||||
// empty payload. Support both — fall through to the per-packet
|
||||
// form when the dot sentinel has no payload, or when the packet
|
||||
// already names a concrete MIME type.
|
||||
if (mimeType === MIME_LISTING_TARGET) {
|
||||
if (!packet.payload) return;
|
||||
const listing = decodeBase64Utf8(packet.payload);
|
||||
if (!listing) return;
|
||||
for (const candidate of listing.split(/\s+/)) {
|
||||
if (candidate && candidate !== MIME_LISTING_TARGET) state.mimes.push(candidate);
|
||||
}
|
||||
return;
|
||||
}
|
||||
state.mimes.push(mimeType);
|
||||
return;
|
||||
}
|
||||
@@ -192,11 +212,12 @@ export class EnhancedPasteController {
|
||||
chunks: [],
|
||||
};
|
||||
|
||||
const metadata = [`type=read`, `mime=${Buffer.from(selected.mimeType, "utf8").toString("base64")}`];
|
||||
const encodedMime = Buffer.from(selected.mimeType, "utf8").toString("base64");
|
||||
const metadata = ["type=read"];
|
||||
if (state.loc) metadata.push(`loc=${state.loc}`);
|
||||
if (state.pw) {
|
||||
metadata.push(`pw=${state.pw}`, `name=${PASTE_EVENT_NAME_BASE64}`);
|
||||
}
|
||||
this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")}${OSC_TERMINATOR_ST}`);
|
||||
this.#handlers.write(`${OSC5522_PREFIX}${metadata.join(":")};${encodedMime}${OSC_TERMINATOR_ST}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,14 @@ function emptyStop(): MockResponse {
|
||||
};
|
||||
}
|
||||
|
||||
function orphanedToolUseStop(): MockResponse {
|
||||
return {
|
||||
content: [{ type: "thinking", thinking: "I should call a tool next." }],
|
||||
stopReason: "toolUse",
|
||||
usage: { output: 1, cacheRead: 100 },
|
||||
};
|
||||
}
|
||||
|
||||
async function createHarness(
|
||||
responses: MockResponse[],
|
||||
settingsOverrides: SettingsOverrides = {},
|
||||
@@ -177,6 +185,45 @@ describe("AgentSession empty stop guard", () => {
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("retries a tool-use stop that has no tool call or text", async () => {
|
||||
const { session, mock } = await createHarness([
|
||||
recordCall("orphan", "call-record-orphan"),
|
||||
orphanedToolUseStop(),
|
||||
{ content: ["finished after orphaned tool-use retry"], stopReason: "stop" },
|
||||
]);
|
||||
|
||||
await session.prompt("record orphan");
|
||||
await session.waitForIdle();
|
||||
|
||||
expect(mock.calls).toHaveLength(3);
|
||||
expect(assistantText(session.agent.state.messages)).toContain("finished after orphaned tool-use retry");
|
||||
expect(reminderMessages(session.agent.state.messages)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("removes orphaned tool-use stops even when retry cap is hit", async () => {
|
||||
const { session, mock } = await createHarness([
|
||||
recordCall("gamma", "call-record-gamma"),
|
||||
orphanedToolUseStop(),
|
||||
orphanedToolUseStop(),
|
||||
orphanedToolUseStop(),
|
||||
orphanedToolUseStop(),
|
||||
]);
|
||||
await session.prompt("record gamma");
|
||||
await session.waitForIdle();
|
||||
expect(mock.calls).toHaveLength(5);
|
||||
expect(reminderMessages(session.agent.state.messages)).toHaveLength(3);
|
||||
const activeBranchMessages = session.sessionManager
|
||||
.getBranch()
|
||||
.filter(entry => entry.type === "message")
|
||||
.map(entry => entry.message as AgentMessage);
|
||||
const orphanedToolUseStops = activeBranchMessages.filter(
|
||||
message =>
|
||||
message.role === "assistant" &&
|
||||
message.stopReason === "toolUse" &&
|
||||
!message.content.some(content => content.type === "toolCall"),
|
||||
);
|
||||
expect(orphanedToolUseStops).toHaveLength(0);
|
||||
});
|
||||
it("caps empty stop retries at three attempts", async () => {
|
||||
const { session, mock } = await createHarness([
|
||||
recordCall("beta", "call-record-beta"),
|
||||
|
||||
@@ -336,4 +336,60 @@ describe("AgentSession retry delay cap", () => {
|
||||
const last = lastAssistant(session);
|
||||
expect(last.stopReason).toBe("stop");
|
||||
});
|
||||
it("retries generic upstream_error gateway failures", async () => {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) {
|
||||
throw new Error("Expected bundled Anthropic test model to exist");
|
||||
}
|
||||
|
||||
const mock = createMockModel({
|
||||
responses: [
|
||||
{ throw: "upstream_error: Upstream request failed" },
|
||||
{ content: ["recovered after generic gateway upstream error"] },
|
||||
],
|
||||
});
|
||||
const agent = new Agent({
|
||||
getApiKey: provider => `${provider}-test-key`,
|
||||
initialState: {
|
||||
model,
|
||||
systemPrompt: ["Test"],
|
||||
tools: [],
|
||||
messages: [],
|
||||
},
|
||||
streamFn: mock.stream,
|
||||
});
|
||||
|
||||
const settings = Settings.isolated({
|
||||
"compaction.enabled": false,
|
||||
"retry.baseDelayMs": 5,
|
||||
"retry.maxDelayMs": 5_000,
|
||||
"retry.maxRetries": 1,
|
||||
});
|
||||
settings.setModelRole("default", `${model.provider}/${model.id}`);
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
settings,
|
||||
modelRegistry,
|
||||
});
|
||||
|
||||
vi.spyOn(scheduler, "wait").mockResolvedValue(undefined);
|
||||
const retryStartEvents: AutoRetryStartEvent[] = [];
|
||||
const retryEndEvents: AutoRetryEndEvent[] = [];
|
||||
session.subscribe(event => {
|
||||
if (event.type === "auto_retry_start") retryStartEvents.push(event);
|
||||
if (event.type === "auto_retry_end") retryEndEvents.push(event);
|
||||
});
|
||||
|
||||
await session.prompt("Trigger generic upstream_error");
|
||||
await session.waitForIdle();
|
||||
|
||||
expect(retryStartEvents).toHaveLength(1);
|
||||
expect(retryEndEvents).toHaveLength(1);
|
||||
expect(retryEndEvents[0]).toMatchObject({ success: true, attempt: 1 });
|
||||
const last = lastAssistant(session);
|
||||
expect(last.stopReason).toBe("stop");
|
||||
expect(last.content).toContainEqual({ type: "text", text: "recovered after generic gateway upstream error" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { getProjectDir, normalizePathForComparison, setProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import { parseArgs } from "../src/cli/args";
|
||||
import { applyStartupCwd } from "../src/cli/startup-cwd";
|
||||
|
||||
const originalProjectDir = getProjectDir();
|
||||
|
||||
afterEach(() => {
|
||||
setProjectDir(originalProjectDir);
|
||||
});
|
||||
describe("parseArgs — --cwd flag", () => {
|
||||
it("parses --cwd with a space-separated directory", () => {
|
||||
const result = parseArgs(["--cwd", "/work/project", "hello"]);
|
||||
|
||||
expect(result.cwd).toBe("/work/project");
|
||||
expect(result.messages).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("parses --cwd=value without leaking the value into messages", () => {
|
||||
const result = parseArgs(["--cwd=/work/project", "hello"]);
|
||||
|
||||
expect(result.cwd).toBe("/work/project");
|
||||
expect(result.messages).toEqual(["hello"]);
|
||||
});
|
||||
|
||||
it("applies --cwd before session lookup callers read the project directory", async () => {
|
||||
const launchDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-cwd-launch-"));
|
||||
const targetDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-cwd-target-"));
|
||||
setProjectDir(launchDir);
|
||||
|
||||
const parsed = parseArgs(["--cwd", targetDir, "--continue"]);
|
||||
await applyStartupCwd(parsed);
|
||||
|
||||
expect(parsed.continue).toBe(true);
|
||||
expect(getProjectDir()).toBe(targetDir);
|
||||
expect(normalizePathForComparison(process.cwd())).toBe(normalizePathForComparison(targetDir));
|
||||
});
|
||||
|
||||
it("normalizes a relative --cwd target to the resolved absolute path", async () => {
|
||||
const launchDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-cwd-rel-"));
|
||||
const childName = "repo";
|
||||
const childDir = path.join(launchDir, childName);
|
||||
fs.mkdirSync(childDir);
|
||||
setProjectDir(launchDir);
|
||||
|
||||
const parsed = parseArgs(["--cwd", childName]);
|
||||
await applyStartupCwd(parsed);
|
||||
|
||||
// parsed.cwd must be the resolved absolute target, not the raw relative
|
||||
// string that would re-resolve against the new cwd (e.g. repo/repo).
|
||||
expect(path.isAbsolute(parsed.cwd ?? "")).toBe(true);
|
||||
expect(parsed.cwd).toBe(getProjectDir());
|
||||
expect(getProjectDir()).toBe(childDir);
|
||||
// Re-resolving the normalized value against the (now changed) process cwd
|
||||
// is idempotent — no doubled "repo/repo" segment.
|
||||
expect(path.resolve(parsed.cwd ?? "")).toBe(getProjectDir());
|
||||
expect(parsed.cwd?.endsWith(`${childName}${path.sep}${childName}`)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -508,10 +508,10 @@ describe("hashline parser — range-anchor syntax", () => {
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it("auto-pipes read-output `N:TEXT` lines inside a pending hunk as literal text", () => {
|
||||
it("strips read-output `N:` line-number prefix from auto-piped bare body rows", () => {
|
||||
const diff = `replace ${tag(2, "bbb")}..${tag(4, "ddd")}:\n${repl("line one")}\n${tag(3, "ccc")}:line two`;
|
||||
const { edits, warnings } = parseHashline(diff);
|
||||
expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee", edits).lines).toBe("aaa\nline one\n3:line two\neee");
|
||||
expect(applyHashlineEdits("aaa\nbbb\nccc\nddd\neee", edits).lines).toBe("aaa\nline one\nline two\neee");
|
||||
expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "bun:test";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, setDefaultTimeout, vi } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
@@ -8,6 +8,11 @@ import * as z from "zod/v4";
|
||||
import { disposeAllVmContexts } from "../../src/eval/js/context-manager";
|
||||
import { executeJs, type JsResult } from "../../src/eval/js/executor";
|
||||
|
||||
// JS eval cold-starts a Bun worker; under --isolate + high CI concurrency that startup
|
||||
// can exceed Bun's 5s default per-test timeout, flaking the suite. Give the worker-backed
|
||||
// tests headroom above the worker-init floor (context-manager WORKER_INIT_TIMEOUT_MS).
|
||||
setDefaultTimeout(20_000);
|
||||
|
||||
function createTool(
|
||||
name: string,
|
||||
execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise<AgentToolResult>,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
||||
import { afterAll, beforeAll, describe, expect, it, setDefaultTimeout } from "bun:test";
|
||||
import * as path from "node:path";
|
||||
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
@@ -6,6 +6,11 @@ import { TempDir } from "@oh-my-pi/pi-utils";
|
||||
import { disposeAllVmContexts } from "../../src/eval/js/context-manager";
|
||||
import { executeJs, type JsResult } from "../../src/eval/js/executor";
|
||||
|
||||
// JS eval cold-starts a Bun worker; under --isolate + high CI concurrency that startup
|
||||
// can exceed Bun's 5s default per-test timeout, flaking the suite. Give the worker-backed
|
||||
// tests headroom above the worker-init floor (context-manager WORKER_INIT_TIMEOUT_MS).
|
||||
setDefaultTimeout(20_000);
|
||||
|
||||
function statusEvents(result: JsResult) {
|
||||
return result.displayOutputs.filter(
|
||||
(output): output is Extract<JsResult["displayOutputs"][number], { type: "status" }> => output.type === "status",
|
||||
|
||||
@@ -154,6 +154,7 @@ function createContext(): {
|
||||
addMessageToChat,
|
||||
cancelPendingSubmission,
|
||||
ensureLoadingAnimation,
|
||||
notifyInterrupting: vi.fn(),
|
||||
finishPendingSubmission: vi.fn(),
|
||||
flushPendingBashComponents: vi.fn(),
|
||||
markPendingSubmissionStarted: vi.fn(() => true),
|
||||
|
||||
@@ -71,6 +71,8 @@ function createStubInputControllerContext(opts: { skillCommands: Map<string, str
|
||||
// Annotate parameters so `mock.calls[N]` is typed as a tuple (not `[]`) and
|
||||
// `message` carries required skill prompt details for assertion below.
|
||||
const promptCustomMessage = vi.fn(async (_message: { details: SkillPromptDetails }, _options?: unknown) => {});
|
||||
const prompt = vi.fn(async (_text: string, _options?: unknown) => {});
|
||||
const handleGoalModeCommand = vi.fn(async (_rest?: string) => {});
|
||||
const updatePendingMessagesDisplay = vi.fn();
|
||||
const requestRender = vi.fn();
|
||||
const showError = vi.fn();
|
||||
@@ -86,9 +88,12 @@ function createStubInputControllerContext(opts: { skillCommands: Map<string, str
|
||||
isEvalRunning: false,
|
||||
extensionRunner: undefined,
|
||||
enqueueCustomMessageDisplay,
|
||||
prompt,
|
||||
promptCustomMessage,
|
||||
},
|
||||
showError,
|
||||
handleGoalModeCommand,
|
||||
goalModeEnabled: false,
|
||||
updatePendingMessagesDisplay,
|
||||
// Defaults that InputController touches on submit but don't matter here.
|
||||
isBashMode: false,
|
||||
@@ -101,7 +106,7 @@ function createStubInputControllerContext(opts: { skillCommands: Map<string, str
|
||||
withLocalSubmission: async (_text: string, fn: () => unknown) => fn(),
|
||||
} as unknown as InteractiveModeContext;
|
||||
|
||||
return { ctx, editor, enqueueCustomMessageDisplay, promptCustomMessage };
|
||||
return { ctx, editor, enqueueCustomMessageDisplay, prompt, promptCustomMessage, handleGoalModeCommand };
|
||||
}
|
||||
|
||||
describe("InputController #invokeSkillCommand (E1-E3)", () => {
|
||||
@@ -166,6 +171,21 @@ describe("InputController #invokeSkillCommand (E1-E3)", () => {
|
||||
expect(messageArg.details.__pendingDisplayTag).toBe("sk-test-0");
|
||||
});
|
||||
|
||||
it("E2b: streaming follow-up applies builtin slash commands instead of queueing them", async () => {
|
||||
const { ctx, editor, prompt, handleGoalModeCommand } = createStubInputControllerContext({
|
||||
skillCommands,
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
const controller = new InputController(ctx);
|
||||
editor.setText("/goal set Ship the release");
|
||||
await controller.handleFollowUp();
|
||||
|
||||
expect(handleGoalModeCommand).toHaveBeenCalledWith("set Ship the release");
|
||||
expect(prompt).not.toHaveBeenCalled();
|
||||
expect(editor.getText()).toBe("");
|
||||
});
|
||||
|
||||
it("E3: not streaming -> enqueueCustomMessageDisplay NOT called and tag absent", async () => {
|
||||
const { ctx, editor, enqueueCustomMessageDisplay, promptCustomMessage } = createStubInputControllerContext({
|
||||
skillCommands,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { afterEach, describe, expect, it, type Mock, vi } from "bun:test";
|
||||
import { InputController } from "../src/modes/controllers/input-controller";
|
||||
import type { InteractiveModeContext } from "../src/modes/types";
|
||||
|
||||
interface SuspendCtx {
|
||||
ctx: InteractiveModeContext;
|
||||
ui: {
|
||||
start: Mock<() => void>;
|
||||
stop: Mock<() => void>;
|
||||
requestRender: Mock<(force?: boolean) => void>;
|
||||
};
|
||||
showStatus: Mock<(message: string) => void>;
|
||||
showError: Mock<(message: string) => void>;
|
||||
}
|
||||
|
||||
function createCtx(): SuspendCtx {
|
||||
const ui = {
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
requestRender: vi.fn(),
|
||||
};
|
||||
const showStatus = vi.fn();
|
||||
const showError = vi.fn();
|
||||
const ctx = {
|
||||
ui: ui as unknown as InteractiveModeContext["ui"],
|
||||
showStatus,
|
||||
showError,
|
||||
} as unknown as InteractiveModeContext;
|
||||
return { ctx, ui, showStatus, showError };
|
||||
}
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
function setPlatform(value: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, "platform", { value, configurable: true, writable: true });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true, writable: true });
|
||||
vi.restoreAllMocks();
|
||||
// Drop any SIGCONT listener a passing test left behind so a later test
|
||||
// (or the next file) doesn't get spurious callbacks.
|
||||
process.removeAllListeners("SIGCONT");
|
||||
});
|
||||
|
||||
describe("InputController.handleCtrlZ", () => {
|
||||
it("no-ops on Windows so the unsupported SIGTSTP signal can't crash the process (#2036)", () => {
|
||||
setPlatform("win32");
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
throw new Error("process.kill must not be called on win32");
|
||||
});
|
||||
const onceSpy = vi.spyOn(process, "once");
|
||||
const { ctx, ui, showStatus, showError } = createCtx();
|
||||
|
||||
const controller = new InputController(ctx);
|
||||
expect(() => controller.handleCtrlZ()).not.toThrow();
|
||||
|
||||
expect(killSpy).not.toHaveBeenCalled();
|
||||
expect(onceSpy).not.toHaveBeenCalledWith("SIGCONT", expect.anything());
|
||||
expect(ui.stop).not.toHaveBeenCalled();
|
||||
expect(ui.start).not.toHaveBeenCalled();
|
||||
expect(showStatus).toHaveBeenCalledTimes(1);
|
||||
expect(showStatus.mock.calls[0]?.[0]).toMatch(/not supported/i);
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends SIGTSTP to the process group and registers a SIGCONT resume hook on POSIX", () => {
|
||||
setPlatform("linux");
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
const onceSpy = vi.spyOn(process, "once");
|
||||
const { ctx, ui, showError } = createCtx();
|
||||
|
||||
const controller = new InputController(ctx);
|
||||
controller.handleCtrlZ();
|
||||
|
||||
// Resume hook registered BEFORE the signal is sent so a same-tick
|
||||
// SIGCONT delivery can't race past us.
|
||||
expect(onceSpy).toHaveBeenCalledWith("SIGCONT", expect.any(Function));
|
||||
const sigcontOrder = onceSpy.mock.invocationCallOrder[0] ?? Infinity;
|
||||
const stopOrder = ui.stop.mock.invocationCallOrder[0] ?? Infinity;
|
||||
const killOrder = killSpy.mock.invocationCallOrder[0] ?? Infinity;
|
||||
expect(sigcontOrder).toBeLessThan(stopOrder);
|
||||
expect(stopOrder).toBeLessThan(killOrder);
|
||||
|
||||
expect(killSpy).toHaveBeenCalledTimes(1);
|
||||
expect(killSpy).toHaveBeenCalledWith(0, "SIGTSTP");
|
||||
expect(ui.start).not.toHaveBeenCalled();
|
||||
expect(showError).not.toHaveBeenCalled();
|
||||
|
||||
// Simulating the kernel-delivered SIGCONT drives the TUI back up.
|
||||
const resume = onceSpy.mock.calls.find(([sig]) => sig === "SIGCONT")?.[1] as (() => void) | undefined;
|
||||
expect(resume).toBeDefined();
|
||||
resume?.();
|
||||
expect(ui.start).toHaveBeenCalledTimes(1);
|
||||
expect(ui.requestRender).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("restores the TUI and drops the SIGCONT listener when process.kill rejects the signal", () => {
|
||||
setPlatform("linux");
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
|
||||
throw new Error("Unknown signal: SIGTSTP");
|
||||
});
|
||||
const onceSpy = vi.spyOn(process, "once");
|
||||
const removeSpy = vi.spyOn(process, "removeListener");
|
||||
const { ctx, ui, showError, showStatus } = createCtx();
|
||||
|
||||
const controller = new InputController(ctx);
|
||||
// Critical contract: the failure must not bubble up to the caller —
|
||||
// otherwise the TUI's stdin reader (which invoked us) crashes the
|
||||
// whole process via `[Uncaught Exception]`.
|
||||
expect(() => controller.handleCtrlZ()).not.toThrow();
|
||||
|
||||
// The exact listener we registered for SIGCONT is the one we
|
||||
// remove; otherwise a leaked handler would fire on the next
|
||||
// unrelated continue and re-`start()` an already-running TUI.
|
||||
const registered = onceSpy.mock.calls.find(([sig]) => sig === "SIGCONT")?.[1];
|
||||
expect(registered).toBeDefined();
|
||||
expect(removeSpy).toHaveBeenCalledWith("SIGCONT", registered);
|
||||
|
||||
expect(killSpy).toHaveBeenCalledTimes(1);
|
||||
expect(ui.stop).toHaveBeenCalledTimes(1);
|
||||
expect(ui.start).toHaveBeenCalledTimes(1);
|
||||
expect(ui.requestRender).toHaveBeenCalledWith(true);
|
||||
expect(showError).toHaveBeenCalledTimes(1);
|
||||
expect(showError.mock.calls[0]?.[0]).toMatch(/Failed to suspend/);
|
||||
expect(showStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,16 @@
|
||||
* Regression: declining the cross-project fork prompt during `--resume <id>`
|
||||
* must exit cleanly, while non-interactive resume still fails instead of
|
||||
* silently succeeding. See #1668.
|
||||
*
|
||||
* Also covers the moved/renamed-worktree path: when the matched session's
|
||||
* recorded directory no longer exists, `--resume <id>` offers to *move*
|
||||
* (re-root) the session rather than fork a duplicate.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as fsp from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { Args } from "@oh-my-pi/pi-coding-agent/cli/args";
|
||||
import type { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import { createSessionManager } from "@oh-my-pi/pi-coding-agent/main";
|
||||
@@ -37,20 +45,27 @@ function buildGlobalMatch(cwd: string): { session: SessionInfo; scope: "global"
|
||||
};
|
||||
}
|
||||
|
||||
const stubSettings = { get: () => undefined } as unknown as Settings;
|
||||
|
||||
describe("createSessionManager — cross-project --resume cancellation (#1668)", () => {
|
||||
afterEach(() => {
|
||||
// An existing directory so the match is treated as a genuinely different
|
||||
// project (fork path), not a moved/renamed worktree (move path).
|
||||
let existingProject: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
existingProject = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-xproj-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fsp.rm(existingProject, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns undefined when an interactive user declines the fork prompt instead of throwing", async () => {
|
||||
const sessionCwd = "/some/other/project";
|
||||
vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(sessionCwd));
|
||||
|
||||
const args = buildArgs("019e84ed");
|
||||
const stubSettings = { get: () => undefined } as unknown as Settings;
|
||||
vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(existingProject));
|
||||
|
||||
const result = await createSessionManager(
|
||||
args,
|
||||
buildArgs("019e84ed"),
|
||||
"/current/project",
|
||||
stubSettings,
|
||||
async () => "declined" as const,
|
||||
@@ -61,15 +76,52 @@ describe("createSessionManager — cross-project --resume cancellation (#1668)",
|
||||
|
||||
it("throws when the cross-project fork prompt is unavailable in non-interactive mode", async () => {
|
||||
expect(process.stdin.isTTY).toBeFalsy();
|
||||
vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(existingProject));
|
||||
|
||||
const sessionCwd = "/some/other/project";
|
||||
vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(sessionCwd));
|
||||
|
||||
const args = buildArgs("019e84ed");
|
||||
const stubSettings = { get: () => undefined } as unknown as Settings;
|
||||
|
||||
await expect(createSessionManager(args, "/current/project", stubSettings)).rejects.toThrow(
|
||||
'Session "019e84ed" is in another project (/some/other/project); run interactively to fork it into the current project.',
|
||||
await expect(createSessionManager(buildArgs("019e84ed"), "/current/project", stubSettings)).rejects.toThrow(
|
||||
`Session "019e84ed" is in another project (${existingProject}); run interactively to fork it into the current project.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSessionManager — cross-project --resume relocation (moved worktree)", () => {
|
||||
let missingRoot: string;
|
||||
let missingProject: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
missingRoot = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-moved-xproj-"));
|
||||
missingProject = path.join(missingRoot, "worktree-gone");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await fsp.rm(missingRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("offers move (not fork) and returns undefined when the user declines", async () => {
|
||||
vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(missingProject));
|
||||
expect(fs.existsSync(missingProject)).toBe(false);
|
||||
|
||||
const forkPrompt = vi.fn(async () => "accepted" as const);
|
||||
const result = await createSessionManager(
|
||||
buildArgs("019e84ed"),
|
||||
"/current/project",
|
||||
stubSettings,
|
||||
forkPrompt,
|
||||
async () => "declined" as const,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
// The fork prompt must NOT be used for a relocated (gone-dir) session.
|
||||
expect(forkPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws the move-specific error when unavailable in non-interactive mode", async () => {
|
||||
expect(process.stdin.isTTY).toBeFalsy();
|
||||
vi.spyOn(sessionManagerModule, "resolveResumableSession").mockResolvedValue(buildGlobalMatch(missingProject));
|
||||
|
||||
await expect(createSessionManager(buildArgs("019e84ed"), "/current/project", stubSettings)).rejects.toThrow(
|
||||
`Session "019e84ed" belongs to a directory that no longer exists (${missingProject}); run interactively to move it into the current project.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
||||
import {
|
||||
EventController,
|
||||
INTERRUPTING_WORKING_MESSAGE,
|
||||
} from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller";
|
||||
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
||||
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
|
||||
import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session";
|
||||
|
||||
function createContext() {
|
||||
const setWorkingMessage = vi.fn();
|
||||
const pendingTools = new Map<string, unknown>();
|
||||
const ctx = {
|
||||
isInitialized: true,
|
||||
settings: { get: () => false },
|
||||
statusLine: { invalidate: vi.fn() },
|
||||
updateEditorTopBorder: vi.fn(),
|
||||
pendingTools,
|
||||
hideThinkingBlock: false,
|
||||
setWorkingMessage,
|
||||
clearPinnedError: vi.fn(),
|
||||
ensureLoadingAnimation: vi.fn(),
|
||||
ui: { setEagerNativeScrollbackRebuild: vi.fn(), requestRender: vi.fn() },
|
||||
session: { getToolByName: () => undefined },
|
||||
} as unknown as InteractiveModeContext;
|
||||
return { ctx, pendingTools, setWorkingMessage };
|
||||
}
|
||||
|
||||
const AGENT_START = { type: "agent_start" } as unknown as AgentSessionEvent;
|
||||
|
||||
/** A `tool_execution_start` whose toolCallId is pre-seeded into `pendingTools`,
|
||||
* so the handler only runs the intent->working-message path and skips component
|
||||
* construction (which needs far heavier mocks). */
|
||||
function toolStartWithIntent(toolCallId: string, intent: string): AgentSessionEvent {
|
||||
return {
|
||||
type: "tool_execution_start",
|
||||
toolCallId,
|
||||
toolName: "search",
|
||||
args: {},
|
||||
intent,
|
||||
} as unknown as AgentSessionEvent;
|
||||
}
|
||||
|
||||
describe("EventController user interrupt acknowledgement", () => {
|
||||
beforeAll(async () => {
|
||||
await initTheme(false);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetSettingsForTest();
|
||||
await Settings.init({ inMemory: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resetSettingsForTest();
|
||||
});
|
||||
|
||||
it("swaps the loader to the interrupting label once a turn is active", async () => {
|
||||
const { ctx, setWorkingMessage } = createContext();
|
||||
const controller = new EventController(ctx);
|
||||
await controller.handleEvent(AGENT_START);
|
||||
|
||||
controller.notifyInterrupting();
|
||||
|
||||
expect(setWorkingMessage).toHaveBeenCalledWith(INTERRUPTING_WORKING_MESSAGE);
|
||||
});
|
||||
|
||||
it("freezes intent-driven working-message updates while interrupting", async () => {
|
||||
const { ctx, pendingTools, setWorkingMessage } = createContext();
|
||||
const controller = new EventController(ctx);
|
||||
await controller.handleEvent(AGENT_START);
|
||||
controller.notifyInterrupting();
|
||||
setWorkingMessage.mockClear();
|
||||
|
||||
// A tool whose args already started streaming before the abort still emits a
|
||||
// late tool_execution_start; without the freeze its intent would repaint the
|
||||
// loader over the "Interrupting…" acknowledgement.
|
||||
pendingTools.set("late-call", {});
|
||||
await controller.handleEvent(toolStartWithIntent("late-call", "Reticulating splines"));
|
||||
|
||||
expect(setWorkingMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets intent updates drive the loader when not interrupting", async () => {
|
||||
const { ctx, pendingTools, setWorkingMessage } = createContext();
|
||||
const controller = new EventController(ctx);
|
||||
await controller.handleEvent(AGENT_START);
|
||||
setWorkingMessage.mockClear();
|
||||
|
||||
pendingTools.set("call-1", {});
|
||||
await controller.handleEvent(toolStartWithIntent("call-1", "Searching files"));
|
||||
|
||||
expect(setWorkingMessage).toHaveBeenCalledTimes(1);
|
||||
expect(setWorkingMessage.mock.calls[0]?.[0]).toContain("Searching files");
|
||||
});
|
||||
|
||||
it("clears the interrupt freeze at the next agent_start", async () => {
|
||||
const { ctx, pendingTools, setWorkingMessage } = createContext();
|
||||
const controller = new EventController(ctx);
|
||||
await controller.handleEvent(AGENT_START);
|
||||
controller.notifyInterrupting();
|
||||
|
||||
// New turn: the freeze must lift so the next turn's intents render again.
|
||||
await controller.handleEvent(AGENT_START);
|
||||
setWorkingMessage.mockClear();
|
||||
|
||||
pendingTools.set("call-2", {});
|
||||
await controller.handleEvent(toolStartWithIntent("call-2", "Editing module"));
|
||||
|
||||
expect(setWorkingMessage).toHaveBeenCalledTimes(1);
|
||||
expect(setWorkingMessage.mock.calls[0]?.[0]).toContain("Editing module");
|
||||
});
|
||||
|
||||
it("is a no-op before any turn starts", () => {
|
||||
const { ctx, setWorkingMessage } = createContext();
|
||||
const controller = new EventController(ctx);
|
||||
|
||||
controller.notifyInterrupting();
|
||||
|
||||
expect(setWorkingMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as fsp from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
loadEntriesFromFile,
|
||||
type SessionHeader,
|
||||
SessionManager,
|
||||
} from "@oh-my-pi/pi-coding-agent/session/session-manager";
|
||||
import { getTerminalId } from "@oh-my-pi/pi-tui";
|
||||
import { getConfigRootDir, getTerminalSessionsDir, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||
|
||||
import { makeAssistantMessage } from "./helpers";
|
||||
|
||||
function getHeader(entries: unknown[]): SessionHeader | undefined {
|
||||
return entries.find(
|
||||
(e): e is SessionHeader =>
|
||||
typeof e === "object" && e !== null && "type" in e && (e as { type: unknown }).type === "session",
|
||||
);
|
||||
}
|
||||
|
||||
function writeBreadcrumb(cwd: string, sessionFile: string): string {
|
||||
const terminalId = getTerminalId();
|
||||
if (!terminalId) throw new Error("Expected a terminal id for breadcrumb test");
|
||||
const dir = getTerminalSessionsDir();
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, terminalId);
|
||||
fs.writeFileSync(file, `${cwd}\n${sessionFile}\n`);
|
||||
return file;
|
||||
}
|
||||
|
||||
function stripHeaderCwd(file: string): void {
|
||||
const lines = fs.readFileSync(file, "utf8").split("\n");
|
||||
const rewritten = lines.map(line => {
|
||||
if (!line.trim()) return line;
|
||||
const obj = JSON.parse(line) as { type?: string; cwd?: unknown };
|
||||
if (obj.type === "session") delete obj.cwd;
|
||||
return JSON.stringify(obj);
|
||||
});
|
||||
fs.writeFileSync(file, rewritten.join("\n"));
|
||||
}
|
||||
|
||||
describe("SessionManager.continueRecent relocation", () => {
|
||||
let testAgentDir: string;
|
||||
let cwdA: string;
|
||||
let cwdB: string;
|
||||
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
const originalTmuxPane = process.env.TMUX_PANE;
|
||||
const fallbackAgentDir = path.join(getConfigRootDir(), "agent");
|
||||
|
||||
beforeEach(async () => {
|
||||
// Force a deterministic, non-TTY terminal id so breadcrumb read/write is stable.
|
||||
process.env.TMUX_PANE = "%relocation-test";
|
||||
testAgentDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-reloc-test-"));
|
||||
setAgentDir(testAgentDir);
|
||||
cwdA = path.join(testAgentDir, "worktree-old");
|
||||
cwdB = path.join(testAgentDir, "worktree-new");
|
||||
fs.mkdirSync(cwdA, { recursive: true });
|
||||
fs.mkdirSync(cwdB, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalTmuxPane === undefined) delete process.env.TMUX_PANE;
|
||||
else process.env.TMUX_PANE = originalTmuxPane;
|
||||
if (originalAgentDir) {
|
||||
setAgentDir(originalAgentDir);
|
||||
} else {
|
||||
setAgentDir(fallbackAgentDir);
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
}
|
||||
await fsp.rm(testAgentDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("re-roots the terminal's session when its directory was moved/renamed", async () => {
|
||||
const session = SessionManager.create(cwdA);
|
||||
session.appendMessage({ role: "user", content: "before move", timestamp: 1 });
|
||||
session.appendMessage(makeAssistantMessage());
|
||||
await session.flush();
|
||||
const oldFile = session.getSessionFile();
|
||||
if (!oldFile) throw new Error("Expected persisted session file");
|
||||
await session.close();
|
||||
|
||||
// Breadcrumb points at the old session, recorded under the old cwd.
|
||||
writeBreadcrumb(cwdA, oldFile);
|
||||
// Simulate `git worktree move`: the old directory no longer exists.
|
||||
await fsp.rm(cwdA, { recursive: true, force: true });
|
||||
|
||||
const resumed = await SessionManager.continueRecent(cwdB);
|
||||
try {
|
||||
// The relocated session is adopted, not discarded for a fresh one.
|
||||
expect(resumed.getCwd()).toBe(path.resolve(cwdB));
|
||||
const newFile = resumed.getSessionFile();
|
||||
if (!newFile) throw new Error("Expected re-rooted session file");
|
||||
expect(newFile).not.toBe(oldFile);
|
||||
expect(fs.existsSync(oldFile)).toBe(false);
|
||||
|
||||
const entries = await loadEntriesFromFile(newFile);
|
||||
expect(getHeader(entries)?.cwd).toBe(path.resolve(cwdB));
|
||||
const userMessages = entries.filter(e => e.type === "message" && e.message.role === "user");
|
||||
expect(userMessages).toHaveLength(1);
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not hijack the session when the recorded directory still exists (plain cd)", async () => {
|
||||
const session = SessionManager.create(cwdA);
|
||||
session.appendMessage({ role: "user", content: "other project", timestamp: 1 });
|
||||
session.appendMessage(makeAssistantMessage());
|
||||
await session.flush();
|
||||
const oldFile = session.getSessionFile();
|
||||
if (!oldFile) throw new Error("Expected persisted session file");
|
||||
await session.close();
|
||||
|
||||
// Breadcrumb from a still-existing different project; user just cd'd elsewhere.
|
||||
writeBreadcrumb(cwdA, oldFile);
|
||||
|
||||
const resumed = await SessionManager.continueRecent(cwdB);
|
||||
try {
|
||||
// Old project's session is left untouched; a fresh session starts in cwdB.
|
||||
expect(fs.existsSync(oldFile)).toBe(true);
|
||||
expect(resumed.getSessionFile()).not.toBe(oldFile);
|
||||
expect(resumed.getEntries()).toHaveLength(0);
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not re-root when the new directory already has its own sessions", async () => {
|
||||
const moved = SessionManager.create(cwdA);
|
||||
moved.appendMessage({ role: "user", content: "moved", timestamp: 1 });
|
||||
moved.appendMessage(makeAssistantMessage());
|
||||
await moved.flush();
|
||||
const movedFile = moved.getSessionFile();
|
||||
if (!movedFile) throw new Error("Expected persisted session file");
|
||||
await moved.close();
|
||||
|
||||
// cwdB already owns a local session.
|
||||
const local = SessionManager.create(cwdB);
|
||||
local.appendMessage({ role: "user", content: "local", timestamp: 2 });
|
||||
local.appendMessage(makeAssistantMessage());
|
||||
await local.flush();
|
||||
const localFile = local.getSessionFile();
|
||||
if (!localFile) throw new Error("Expected persisted local session file");
|
||||
await local.close();
|
||||
|
||||
writeBreadcrumb(cwdA, movedFile);
|
||||
await fsp.rm(cwdA, { recursive: true, force: true });
|
||||
|
||||
const resumed = await SessionManager.continueRecent(cwdB);
|
||||
try {
|
||||
// Prefer cwdB's own recent session over re-rooting the moved one.
|
||||
expect(resumed.getSessionFile()).toBe(localFile);
|
||||
expect(fs.existsSync(movedFile)).toBe(true);
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("moves a relocated breadcrumb session into an explicit sessionDir", async () => {
|
||||
const session = SessionManager.create(cwdA);
|
||||
session.appendMessage({ role: "user", content: "explicit dir", timestamp: 1 });
|
||||
session.appendMessage(makeAssistantMessage());
|
||||
await session.flush();
|
||||
const oldFile = session.getSessionFile();
|
||||
if (!oldFile) throw new Error("Expected persisted session file");
|
||||
await session.close();
|
||||
|
||||
const explicitSessionDir = path.join(testAgentDir, "custom-sessions");
|
||||
writeBreadcrumb(cwdA, oldFile);
|
||||
await fsp.rm(cwdA, { recursive: true, force: true });
|
||||
|
||||
const resumed = await SessionManager.continueRecent(cwdB, explicitSessionDir);
|
||||
try {
|
||||
const newFile = resumed.getSessionFile();
|
||||
if (!newFile) throw new Error("Expected re-rooted session file");
|
||||
expect(path.dirname(newFile)).toBe(path.resolve(explicitSessionDir));
|
||||
expect(fs.existsSync(oldFile)).toBe(false);
|
||||
expect(getHeader(await loadEntriesFromFile(newFile))?.cwd).toBe(path.resolve(cwdB));
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-roots when the stale breadcrumb file is already in the explicit sessionDir", async () => {
|
||||
const explicitSessionDir = path.join(testAgentDir, "shared-custom-sessions");
|
||||
const session = SessionManager.create(cwdA, explicitSessionDir);
|
||||
session.appendMessage({ role: "user", content: "same explicit dir", timestamp: 1 });
|
||||
session.appendMessage(makeAssistantMessage());
|
||||
await session.flush();
|
||||
const oldFile = session.getSessionFile();
|
||||
if (!oldFile) throw new Error("Expected persisted session file");
|
||||
expect(path.dirname(oldFile)).toBe(path.resolve(explicitSessionDir));
|
||||
await session.close();
|
||||
|
||||
writeBreadcrumb(cwdA, oldFile);
|
||||
await fsp.rm(cwdA, { recursive: true, force: true });
|
||||
|
||||
const resumed = await SessionManager.continueRecent(cwdB, explicitSessionDir);
|
||||
try {
|
||||
const newFile = resumed.getSessionFile();
|
||||
if (!newFile) throw new Error("Expected re-rooted session file");
|
||||
expect(newFile).toBe(oldFile);
|
||||
expect(resumed.getCwd()).toBe(path.resolve(cwdB));
|
||||
expect(getHeader(await loadEntriesFromFile(newFile))?.cwd).toBe(path.resolve(cwdB));
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers an existing current-cwd session in a shared explicit sessionDir", async () => {
|
||||
const explicitSessionDir = path.join(testAgentDir, "shared-current-sessions");
|
||||
const local = SessionManager.create(cwdB, explicitSessionDir);
|
||||
local.appendMessage({ role: "user", content: "local current cwd", timestamp: 1 });
|
||||
local.appendMessage(makeAssistantMessage());
|
||||
await local.flush();
|
||||
const localFile = local.getSessionFile();
|
||||
if (!localFile) throw new Error("Expected persisted local session file");
|
||||
await local.close();
|
||||
|
||||
// Ensure the stale moved session is newer than the local current-cwd session.
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
const moved = SessionManager.create(cwdA, explicitSessionDir);
|
||||
moved.appendMessage({ role: "user", content: "newer stale moved cwd", timestamp: 2 });
|
||||
moved.appendMessage(makeAssistantMessage());
|
||||
await moved.flush();
|
||||
const movedFile = moved.getSessionFile();
|
||||
if (!movedFile) throw new Error("Expected persisted moved session file");
|
||||
await moved.close();
|
||||
|
||||
writeBreadcrumb(cwdA, movedFile);
|
||||
await fsp.rm(cwdA, { recursive: true, force: true });
|
||||
|
||||
const resumed = await SessionManager.continueRecent(cwdB, explicitSessionDir);
|
||||
try {
|
||||
expect(resumed.getSessionFile()).toBe(localFile);
|
||||
expect(resumed.getCwd()).toBe(path.resolve(cwdB));
|
||||
expect(fs.existsSync(movedFile)).toBe(true);
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("re-roots past a cwd-less legacy session in a shared explicit sessionDir", async () => {
|
||||
// Regression: SessionInfo.cwd is "" for sessions whose header has no cwd, and
|
||||
// path.resolve("") === process.cwd(). A guard that only excluded `undefined`
|
||||
// treated such a legacy session as "belongs to the current cwd" whenever
|
||||
// --continue ran from process.cwd(), hijacking the moved session. Resume must
|
||||
// be invoked with process.cwd() to reproduce the path.resolve("") collision.
|
||||
const explicitSessionDir = path.join(testAgentDir, "shared-legacy-sessions");
|
||||
const currentCwd = process.cwd();
|
||||
|
||||
// Older session with no recorded cwd (header cwd stripped → "" on load).
|
||||
const legacy = SessionManager.create(cwdB, explicitSessionDir);
|
||||
legacy.appendMessage({ role: "user", content: "legacy cwd-less", timestamp: 1 });
|
||||
legacy.appendMessage(makeAssistantMessage());
|
||||
await legacy.flush();
|
||||
const legacyFile = legacy.getSessionFile();
|
||||
if (!legacyFile) throw new Error("Expected persisted legacy session file");
|
||||
await legacy.close();
|
||||
stripHeaderCwd(legacyFile);
|
||||
|
||||
// Newer moved session, recorded under the now-missing worktree cwd.
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
const moved = SessionManager.create(cwdA, explicitSessionDir);
|
||||
moved.appendMessage({ role: "user", content: "newer moved cwd", timestamp: 2 });
|
||||
moved.appendMessage(makeAssistantMessage());
|
||||
await moved.flush();
|
||||
const movedFile = moved.getSessionFile();
|
||||
if (!movedFile) throw new Error("Expected persisted moved session file");
|
||||
await moved.close();
|
||||
|
||||
writeBreadcrumb(cwdA, movedFile);
|
||||
await fsp.rm(cwdA, { recursive: true, force: true });
|
||||
|
||||
const resumed = await SessionManager.continueRecent(currentCwd, explicitSessionDir);
|
||||
try {
|
||||
// The moved session is re-rooted; the cwd-less legacy session is not hijacked.
|
||||
expect(resumed.getSessionFile()).toBe(movedFile);
|
||||
expect(resumed.getCwd()).toBe(path.resolve(currentCwd));
|
||||
expect(fs.existsSync(legacyFile)).toBe(true);
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -115,6 +115,17 @@ describe("MCP fallback and prompt formatting", () => {
|
||||
expect(resolveApproval(subject, {}, "yolo")).toMatchObject({ policy: "allow", tier: "exec" });
|
||||
});
|
||||
|
||||
it("allows MCP tools with write approval in write mode", () => {
|
||||
const subject = tool("mcp__server__safe", "write");
|
||||
expect(resolveApproval(subject, {}, "write")).toMatchObject({ policy: "allow", tier: "write" });
|
||||
expect(resolveApproval(subject, {}, "yolo")).toMatchObject({ policy: "allow", tier: "write" });
|
||||
});
|
||||
|
||||
it("prompts for MCP tools with write approval in always-ask mode", () => {
|
||||
const subject = tool("mcp__server__safe", "write");
|
||||
expect(resolveApproval(subject, {}, "always-ask")).toMatchObject({ policy: "prompt", tier: "write" });
|
||||
});
|
||||
|
||||
it("formats MCP origin, reason, and per-tool details", () => {
|
||||
const subject = tool("mcp__server__dangerous", undefined, () => ["Path: /tmp/out", "Content:\nhello"]);
|
||||
expect(formatApprovalPrompt(subject, {}, "Needs confirmation").split("\n")).toEqual([
|
||||
|
||||
@@ -132,3 +132,170 @@ describe("toReviewFinding", () => {
|
||||
expect(parsed.findings[0].priority).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findings injection respects active output schema", () => {
|
||||
const finding = toReviewFinding({
|
||||
title: "[P0] Example finding",
|
||||
body: "Details",
|
||||
priority: "P0",
|
||||
confidence: 0.95,
|
||||
file_path: "/tmp/example.ts",
|
||||
line_start: 10,
|
||||
line_end: 12,
|
||||
});
|
||||
|
||||
// Reproduces #2070: a caller-supplied JSON Schema with
|
||||
// `additionalProperties: false` and no `findings` property is silently
|
||||
// rejected post-mortem after the in-tool yield accepted it, because the
|
||||
// executor auto-injects `findings` from `report_finding`. The injection
|
||||
// must respect the active schema so that "accepted in-tool ⇒ accepted
|
||||
// post-mortem" is honored.
|
||||
it("suppresses findings injection when the schema forbids additional properties", () => {
|
||||
const callerSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["verdict", "acceptance_summary"],
|
||||
properties: {
|
||||
verdict: { enum: ["accept", "needs-work", "honest-stop"] },
|
||||
acceptance_summary: { type: "string" },
|
||||
},
|
||||
};
|
||||
const data = { verdict: "accept", acceptance_summary: "All good." };
|
||||
|
||||
const result = finalizeSubprocessOutput({
|
||||
rawOutput: "",
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
doneAborted: false,
|
||||
signalAborted: false,
|
||||
yieldItems: [{ status: "success", data }],
|
||||
reportFindings: [finding],
|
||||
outputSchema: callerSchema,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const parsed = JSON.parse(result.rawOutput) as Record<string, unknown>;
|
||||
expect(parsed).toEqual(data);
|
||||
expect("findings" in parsed).toBe(false);
|
||||
});
|
||||
|
||||
it("still injects findings when the schema declares them (bundled reviewer JTD)", () => {
|
||||
// Mirrors the bundled reviewer agent's output: findings live under
|
||||
// `optionalProperties`, so injection must continue to flow through.
|
||||
const reviewerSchema = {
|
||||
properties: {
|
||||
overall_correctness: { enum: ["correct", "incorrect"] },
|
||||
explanation: { type: "string" },
|
||||
confidence: { type: "number" },
|
||||
},
|
||||
optionalProperties: {
|
||||
findings: {
|
||||
elements: {
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
body: { type: "string" },
|
||||
priority: { type: "number" },
|
||||
confidence: { type: "number" },
|
||||
file_path: { type: "string" },
|
||||
line_start: { type: "number" },
|
||||
line_end: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const data = {
|
||||
overall_correctness: "incorrect",
|
||||
explanation: "Found one bug",
|
||||
confidence: 0.9,
|
||||
};
|
||||
|
||||
const result = finalizeSubprocessOutput({
|
||||
rawOutput: "",
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
doneAborted: false,
|
||||
signalAborted: false,
|
||||
yieldItems: [{ status: "success", data }],
|
||||
reportFindings: [finding],
|
||||
outputSchema: reviewerSchema,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const parsed = JSON.parse(result.rawOutput) as { findings: Array<{ priority: number }> };
|
||||
expect(parsed.findings).toHaveLength(1);
|
||||
expect(parsed.findings[0].priority).toBe(0);
|
||||
});
|
||||
|
||||
it("still injects findings when no schema is declared (legacy free-form)", () => {
|
||||
const data = { note: "freeform" };
|
||||
|
||||
const result = finalizeSubprocessOutput({
|
||||
rawOutput: "",
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
doneAborted: false,
|
||||
signalAborted: false,
|
||||
yieldItems: [{ status: "success", data }],
|
||||
reportFindings: [finding],
|
||||
outputSchema: undefined,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(result.rawOutput) as { findings?: unknown[] };
|
||||
expect(parsed.findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still injects findings when additionalProperties is open", () => {
|
||||
const openSchema = {
|
||||
type: "object",
|
||||
required: ["verdict"],
|
||||
properties: { verdict: { type: "string" } },
|
||||
};
|
||||
const data = { verdict: "accept" };
|
||||
|
||||
const result = finalizeSubprocessOutput({
|
||||
rawOutput: "",
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
doneAborted: false,
|
||||
signalAborted: false,
|
||||
yieldItems: [{ status: "success", data }],
|
||||
reportFindings: [finding],
|
||||
outputSchema: openSchema,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
const parsed = JSON.parse(result.rawOutput) as { findings?: unknown[]; verdict: string };
|
||||
expect(parsed.verdict).toBe("accept");
|
||||
expect(parsed.findings).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("suppresses injection on the fallback (no-yield) path when the schema forbids it", () => {
|
||||
const callerSchema = {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
required: ["verdict"],
|
||||
properties: { verdict: { type: "string" } },
|
||||
};
|
||||
const rawJson = JSON.stringify({ data: { verdict: "accept" } });
|
||||
|
||||
const result = finalizeSubprocessOutput({
|
||||
rawOutput: rawJson,
|
||||
exitCode: 0,
|
||||
stderr: "",
|
||||
doneAborted: false,
|
||||
signalAborted: false,
|
||||
yieldItems: undefined,
|
||||
reportFindings: [finding],
|
||||
outputSchema: callerSchema,
|
||||
});
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).toBe("");
|
||||
const parsed = JSON.parse(result.rawOutput) as Record<string, unknown>;
|
||||
expect(parsed).toEqual({ verdict: "accept" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("EnhancedPasteController", () => {
|
||||
controller.handleInput(packet("type=read:status=DONE"));
|
||||
|
||||
const pasteEventName = Buffer.from("Paste event", "utf8").toString("base64");
|
||||
expect(writes.at(-1)).toBe(`${OSC}type=read:mime=${imageMime}:pw=${password}:name=${pasteEventName}${ST}`);
|
||||
expect(writes.at(-1)).toBe(`${OSC}type=read:pw=${password}:name=${pasteEventName};${imageMime}${ST}`);
|
||||
|
||||
controller.handleInput(packet("type=read:status=OK"));
|
||||
controller.handleInput(
|
||||
@@ -68,15 +68,13 @@ describe("EnhancedPasteController", () => {
|
||||
|
||||
const textMime = Buffer.from("text/plain", "utf8").toString("base64");
|
||||
const password = Buffer.from("secret456", "utf8").toString("base64");
|
||||
const pasteEventName = Buffer.from("Paste event", "utf8").toString("base64");
|
||||
expect(controller.handleInput("plain text")).toBe(false);
|
||||
controller.handleInput(packet(`type=read:status=OK:loc=primary:pw=${password}`));
|
||||
controller.handleInput(packet(`type=read:status=DATA:mime=${textMime}`));
|
||||
controller.handleInput(packet("type=read:status=DONE"));
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(writes[0]).toContain(`mime=${textMime}`);
|
||||
expect(writes[0]).toContain("loc=primary");
|
||||
expect(writes[0]).toContain(`pw=${password}`);
|
||||
expect(writes).toEqual([`${OSC}type=read:loc=primary:pw=${password}:name=${pasteEventName};${textMime}${ST}`]);
|
||||
|
||||
controller.handleInput(packet("type=read:status=OK"));
|
||||
controller.handleInput(
|
||||
@@ -106,4 +104,73 @@ describe("EnhancedPasteController", () => {
|
||||
|
||||
expect(statuses).toEqual(["Clipboard paste has no supported text or image data"]);
|
||||
});
|
||||
|
||||
it("decodes Kitty's dot-listing DATA payload to discover plain-text and request it", () => {
|
||||
const writes: string[] = [];
|
||||
const pastedText: string[] = [];
|
||||
const controller = new EnhancedPasteController({
|
||||
write: data => writes.push(data),
|
||||
pasteText: text => pastedText.push(text),
|
||||
pasteImage: () => {
|
||||
throw new Error("unexpected image paste");
|
||||
},
|
||||
showStatus: message => pastedText.push(`status:${message}`),
|
||||
});
|
||||
|
||||
const dot = Buffer.from(".", "utf8").toString("base64");
|
||||
const textMime = Buffer.from("text/plain", "utf8").toString("base64");
|
||||
const password = Buffer.from("secret-token-123", "utf8").toString("base64");
|
||||
const pasteEventName = Buffer.from("Paste event", "utf8").toString("base64");
|
||||
|
||||
// Kitty bundles the available MIME types into a single DATA packet
|
||||
// whose `mime` field is the literal `.` and whose payload carries a
|
||||
// whitespace-separated, base64-encoded list (e.g. "text/plain\n").
|
||||
controller.handleInput(packet(`type=read:status=OK:pw=${password}`));
|
||||
controller.handleInput(
|
||||
packet(
|
||||
`type=read:status=DATA:mime=${dot}:pw=${password}`,
|
||||
Buffer.from("text/plain\n", "utf8").toString("base64"),
|
||||
),
|
||||
);
|
||||
controller.handleInput(packet(`type=read:status=DONE:pw=${password}`));
|
||||
|
||||
expect(writes.at(-1)).toBe(`${OSC}type=read:pw=${password}:name=${pasteEventName};${textMime}${ST}`);
|
||||
|
||||
controller.handleInput(packet("type=read:status=OK"));
|
||||
controller.handleInput(
|
||||
packet(`type=read:status=DATA:mime=${textMime}`, Buffer.from("hello", "utf8").toString("base64")),
|
||||
);
|
||||
controller.handleInput(
|
||||
packet(`type=read:status=DATA:mime=${textMime}`, Buffer.from(" world", "utf8").toString("base64")),
|
||||
);
|
||||
controller.handleInput(packet("type=read:status=DONE"));
|
||||
|
||||
expect(pastedText).toEqual(["hello world"]);
|
||||
});
|
||||
|
||||
it("prefers images when Kitty's dot-listing payload advertises multiple MIME types", () => {
|
||||
const writes: string[] = [];
|
||||
const controller = new EnhancedPasteController({
|
||||
write: data => writes.push(data),
|
||||
pasteText: () => {
|
||||
throw new Error("unexpected text paste");
|
||||
},
|
||||
pasteImage: () => {},
|
||||
showStatus: () => {},
|
||||
});
|
||||
|
||||
const dot = Buffer.from(".", "utf8").toString("base64");
|
||||
const imageMime = Buffer.from("image/png", "utf8").toString("base64");
|
||||
|
||||
controller.handleInput(packet("type=read:status=OK"));
|
||||
controller.handleInput(
|
||||
packet(
|
||||
`type=read:status=DATA:mime=${dot}`,
|
||||
Buffer.from("text/plain image/png text/html\n", "utf8").toString("base64"),
|
||||
),
|
||||
);
|
||||
controller.handleInput(packet("type=read:status=DONE"));
|
||||
|
||||
expect(writes.at(-1)).toBe(`${OSC}type=read;${imageMime}${ST}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Stripped read-output line-number prefixes (`N:`) from auto-piped bare body rows so that pasting `3:text` without a `+` prefix no longer injects `3:` as literal content. Stripping is applied only when *every* bare row in the hunk carries the prefix (the signature of a pasted snapshot) and removes at most one prefix per row, so a genuine body that merely starts with `digits:` (YAML port maps, timestamps) is left intact ([#1492](https://github.com/can1357/oh-my-pi/issues/1492)).
|
||||
|
||||
## [15.9.67] - 2026-06-06
|
||||
|
||||
### Breaking Changes
|
||||
@@ -40,7 +44,7 @@
|
||||
### Added
|
||||
|
||||
- Added `maxPaths` and `maxVersionsPerPath` options to `InMemorySnapshotStore` to bound tracked paths and per-path snapshot history
|
||||
- Re-introduced balance-validated boundary repair in `applyEdits`. A replacement hunk (`replace N..M:` + body) is normalized so its payload preserves the deleted region's delimiter balance: when the body restates a closing delimiter that survives just outside the range (duplicate `}` / `);` / `]`) the echo is dropped, and when the range deletes a structural closer the body never restates (missing closer) the closer is spared instead of deleted. A repair fires only when one boundary operation drives the per-channel `()` / `[]` / `{}` imbalance to exactly zero while leaving surrounding text byte-identical (single-line ops are limited to pure structural-closer lines), so balance-preserving edits and intentional balanced duplicates are never touched. Bracket counting skips strings, template literals, and comments. Each repair surfaces a `delimiter-balance` warning through `ApplyResult.warnings`.
|
||||
- Re-introduced balance-validated boundary repair in `applyEdits`. A replacement hunk (`replace N..M:` + body) is normalized so its payload preserves the deleted region's delimiter balance: when the body restates a closing delimiter that survives just outside the range (duplicate `}` / `);` / `]`) the echo is dropped, and when the range deletes a structural closer the body never restates (missing closer) the closer is spared instead of deleted. A repair fires only when one boundary operation drives the per-channel `()` / `[]` / `{}` imbalance to exactly zero while leaving surrounding text byte-identical (single-line ops are limited to pure structural-closer lines), so balance-preserving edits and intentional balanced duplicates are never touched. Bracket couples are also bounded by line count: structural balance delta repair is capped to 10 duplicate lines across all channels combined, massive balanced blocks are skipped.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
EMPTY_INSERT,
|
||||
MINUS_ROW_REJECTED,
|
||||
} from "./messages";
|
||||
import { stripOneLeadingHashlinePrefix } from "./prefixes";
|
||||
import { type BlockTarget, cloneCursor, type ParsedRange, type Token, Tokenizer } from "./tokenizer";
|
||||
import type { Anchor, Cursor, Edit } from "./types";
|
||||
|
||||
@@ -81,7 +82,7 @@ interface PendingComment {
|
||||
text: string;
|
||||
}
|
||||
|
||||
type PayloadRow = { kind: "literal"; text: string; lineNum: number };
|
||||
type PayloadRow = { kind: "literal"; text: string; lineNum: number; bare?: boolean };
|
||||
|
||||
interface Pending {
|
||||
target: BlockTarget;
|
||||
@@ -220,7 +221,14 @@ export class Executor {
|
||||
throw new Error(`line ${lineNum}: ${DELETE_BLOCK_TAKES_NO_BODY}`);
|
||||
if (text.trimStart().charCodeAt(0) === 45 /* - */) throw new Error(`line ${lineNum}: ${MINUS_ROW_REJECTED}`);
|
||||
if (!this.#warnings.includes(BARE_BODY_AUTO_PIPED_WARNING)) this.#warnings.push(BARE_BODY_AUTO_PIPED_WARNING);
|
||||
this.#pending.payloads.push({ kind: "literal", text, lineNum });
|
||||
// Defer read-output line-number stripping to #flushPending: a bare
|
||||
// "N:text" row is only a copy-paste artifact from snapshot output
|
||||
// when *every* bare row in the hunk carries that prefix. Stripping a
|
||||
// row in isolation would corrupt a genuine body that merely starts
|
||||
// with "digits:" (YAML ports "42:hello", timestamps "12:30") when it
|
||||
// sits next to an unprefixed sibling. Rows with an explicit "+" go
|
||||
// through #handleLiteralPayload and are never bare, never stripped.
|
||||
this.#pending.payloads.push({ kind: "literal", text, lineNum, bare: true });
|
||||
return;
|
||||
}
|
||||
if (text.trim().length === 0) return;
|
||||
@@ -230,6 +238,26 @@ export class Executor {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a single read-output line-number prefix (`N:`) from every bare body
|
||||
* row, but only when *all* bare rows carry one. A uniform set of prefixes is
|
||||
* the signature of content pasted straight from `read`/`search` output; a
|
||||
* mixed set means the `N:` is genuine payload content and must stay. Rows
|
||||
* authored with an explicit `+` are not bare and are never touched.
|
||||
*/
|
||||
#stripBarePrefixesIfUniform(payloads: PayloadRow[]): void {
|
||||
let sawBare = false;
|
||||
for (const row of payloads) {
|
||||
if (!row.bare) continue;
|
||||
sawBare = true;
|
||||
if (stripOneLeadingHashlinePrefix(row.text) === row.text) return;
|
||||
}
|
||||
if (!sawBare) return;
|
||||
for (const row of payloads) {
|
||||
if (row.bare) row.text = stripOneLeadingHashlinePrefix(row.text);
|
||||
}
|
||||
}
|
||||
|
||||
#pushInsert(cursor: Cursor, text: string, lineNum: number, mode?: "replacement"): void {
|
||||
this.#edits.push({
|
||||
kind: "insert",
|
||||
@@ -263,6 +291,7 @@ export class Executor {
|
||||
const pending = this.#pending;
|
||||
if (!pending) return;
|
||||
const { target, lineNum, payloads } = pending;
|
||||
this.#stripBarePrefixesIfUniform(payloads);
|
||||
this.#pending = undefined;
|
||||
if (target.kind === "delete") {
|
||||
for (const anchor of expandRange(target.range)) this.#pushDelete(anchor, lineNum);
|
||||
|
||||
@@ -31,6 +31,16 @@ function stripLeadingHashlinePrefixes(line: string): string {
|
||||
} while (result !== previous);
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* Single-pass variant of {@link stripLeadingHashlinePrefixes} that strips at
|
||||
* most one leading hashline prefix (`N:`, `>>>N:`, `+N:` etc.) and does NOT
|
||||
* loop. Use this when the input carries at most one snapshot prefix (e.g. a
|
||||
* bare body row paste from `read` output) — recursive stripping would corrupt
|
||||
* content whose own text starts with `digits:`.
|
||||
*/
|
||||
export function stripOneLeadingHashlinePrefix(line: string): string {
|
||||
return line.replace(HL_PREFIX_RE, "");
|
||||
}
|
||||
|
||||
interface LinePrefixStats {
|
||||
nonEmpty: number;
|
||||
|
||||
@@ -52,6 +52,15 @@ describe("hashline format v4", () => {
|
||||
expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true);
|
||||
});
|
||||
|
||||
it("strips read-output line number prefix from auto-piped bare body rows", () => {
|
||||
const text = "a\nb\nc";
|
||||
// Without this fix, "3:text" becomes literal "3:text" in the file.
|
||||
// With the fix, the "3:" prefix is stripped, yielding just "text".
|
||||
const { edits, warnings } = parsePatch("replace 2..2:\n3:replaced");
|
||||
expect(applyEdits(text, edits).text).toBe("a\nreplaced\nc");
|
||||
expect(warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true);
|
||||
});
|
||||
|
||||
it("validates insert anchors against file bounds", () => {
|
||||
const edits = parsePatch("insert before 4:\n+x").edits;
|
||||
expect(() => applyEdits("a\nb", edits)).toThrow(/Line 4 does not exist/);
|
||||
|
||||
@@ -106,6 +106,34 @@ describe("hashline body contracts", () => {
|
||||
expect(result.warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true);
|
||||
});
|
||||
|
||||
it("strips read-output line number prefix from auto-piped bare body rows", () => {
|
||||
const result = parsePatch("replace 2..2:\n2:hello");
|
||||
expect(applyEdits(FILE, result.edits).text).toBe("a\nhello\nc\nd\ne");
|
||||
expect(result.warnings.some(w => /Auto-prefixed bare body row/.test(w))).toBe(true);
|
||||
});
|
||||
it("preserves `+N:` literal payloads without stripping", () => {
|
||||
const result = parsePatch("replace 2..2:\n+3:keep");
|
||||
expect(applyEdits(FILE, result.edits).text).toBe("a\n3:keep\nc\nd\ne");
|
||||
expect(result.warnings.some(w => /Auto-prefixed/.test(w))).toBe(false);
|
||||
});
|
||||
it("strips only one N: prefix from bare body rows (preserves nested digits:colon)", () => {
|
||||
// "2:42:hello" → should yield "42:hello", NOT "hello" (recursive would over-strip)
|
||||
const result = parsePatch("replace 2..2:\n2:42:hello");
|
||||
expect(applyEdits(FILE, result.edits).text).toBe("a\n42:hello\nc\nd\ne");
|
||||
});
|
||||
|
||||
it("strips N: prefixes only when every bare body row carries one", () => {
|
||||
const result = parsePatch("replace 2..3:\n2:foo\n3:bar");
|
||||
expect(applyEdits(FILE, result.edits).text).toBe("a\nfoo\nbar\nd\ne");
|
||||
});
|
||||
|
||||
it("leaves bare body rows untouched when only some carry an N: prefix", () => {
|
||||
// "3:keep" looks like a snapshot prefix but "plain" does not, so the body
|
||||
// is genuine content (not a pasted snapshot) — strip nothing.
|
||||
const result = parsePatch("replace 2..3:\n3:keep\nplain");
|
||||
expect(applyEdits(FILE, result.edits).text).toBe("a\n3:keep\nplain\nd\ne");
|
||||
});
|
||||
|
||||
it("rejects `-` body rows with a teaching error", () => {
|
||||
expect(() => parsePatch("replace 2..2:\n-old\n+new")).toThrow(/`-` rows are not valid/);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added the `super` modifier to `matchesKey` / `parseKey` / `parseKittySequence`. Key identifiers may now include `super+` (anywhere in the modifier prefix), and Kitty CSI-u sequences whose modifier mask contains the super bit (8) — e.g. Ghostty's macOS Option+Backspace `ESC [127;11u` — are now recognised instead of dropped ([#2064](https://github.com/can1357/oh-my-pi/issues/2064)).
|
||||
|
||||
## [15.10.1] - 2026-06-07
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Added `super` modifier support to native key parsing/matching and bound `super+alt+backspace` / `super+alt+delete` (and `super+alt+d`) into the word-delete defaults so Ghostty's default macOS Option+Backspace wire (`ESC [127;11u` — kitty modifier 11 = super|alt) deletes a word instead of falling through to single-char delete ([#2064](https://github.com/can1357/oh-my-pi/issues/2064)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the kitty keyboard progressive-enhancement probe to honor the `CSI ? <flags> u` reply even when the terminal answers the DA1 sentinel first. Previously the kitty reply was discarded once the DA1-driven `modifyOtherKeys` fallback engaged, so terminals like Superset/xterm-on-Electron stayed on the fallback and delivered Shift+Enter as a bare `\r` ([#2042](https://github.com/can1357/oh-my-pi/issues/2042)).
|
||||
- Bounded TUI line fitting for oversized raw rows so ANSI-heavy subagent output cannot grow render buffers independently of the viewport ([#2045](https://github.com/can1357/oh-my-pi/issues/2045)).
|
||||
- Fixed tmux offscreen-shrink frames to skip repainting when the visible tail is unchanged, avoiding intermittent blank/refresh flashes in pane terminals ([#2046](https://github.com/can1357/oh-my-pi/issues/2046)).
|
||||
- Fixed Windows ConPTY hosts (Windows Terminal, Tabby, Hyper, VS Code) parking the viewport at the top of a full paint after a `/resume` or any long-session repaint. `ProcessTerminal#safeWrite` now splits oversized writes into ≤ 8 KiB pieces at line boundaries on `win32` and inside WSL (where stdout still crosses ConPTY at the `wslhost` boundary) so each underlying `WriteFile` stays below the ~32 KiB threshold where ConPTY stops tracking the cursor; the data was always delivered, but the host UI's scroll position would not follow until any focus event forced a re-query. ([#2034](https://github.com/can1357/oh-my-pi/issues/2034))
|
||||
|
||||
## [15.10.1] - 2026-06-07
|
||||
### Breaking Changes
|
||||
|
||||
|
||||
@@ -1109,12 +1109,18 @@ export class Editor implements Component, Focusable {
|
||||
else if (matchesKey(data, "ctrl+w")) {
|
||||
this.#deleteWordBackwards();
|
||||
}
|
||||
// Option/Alt+Backspace - Delete word backwards
|
||||
else if (matchesKey(data, "alt+backspace")) {
|
||||
// Option/Alt+Backspace - Delete word backwards.
|
||||
// Ghostty on macOS reports Option+Backspace as super+alt (kitty mod 11) — see #2064.
|
||||
else if (matchesKey(data, "alt+backspace") || matchesKey(data, "super+alt+backspace")) {
|
||||
this.#deleteWordBackwards();
|
||||
}
|
||||
// Option/Alt+D - Delete word forwards
|
||||
else if (matchesKey(data, "alt+d") || matchesKey(data, "alt+delete")) {
|
||||
// Option/Alt+D and Option+Delete - Delete word forwards. Same Ghostty quirk applies.
|
||||
else if (
|
||||
matchesKey(data, "alt+d") ||
|
||||
matchesKey(data, "alt+delete") ||
|
||||
matchesKey(data, "super+alt+d") ||
|
||||
matchesKey(data, "super+alt+delete")
|
||||
) {
|
||||
this.#deleteWordForwards();
|
||||
}
|
||||
// Ctrl+Y - Yank from kill ring
|
||||
|
||||
@@ -100,11 +100,11 @@ export const TUI_KEYBINDINGS = {
|
||||
description: "Delete character forward",
|
||||
},
|
||||
"tui.editor.deleteWordBackward": {
|
||||
defaultKeys: ["ctrl+w", "alt+backspace", "ctrl+backspace"],
|
||||
defaultKeys: ["ctrl+w", "alt+backspace", "ctrl+backspace", "super+alt+backspace"],
|
||||
description: "Delete word backward",
|
||||
},
|
||||
"tui.editor.deleteWordForward": {
|
||||
defaultKeys: ["alt+delete", "alt+d"],
|
||||
defaultKeys: ["alt+delete", "alt+d", "super+alt+delete", "super+alt+d"],
|
||||
description: "Delete word forward",
|
||||
},
|
||||
"tui.editor.deleteToLineStart": {
|
||||
|
||||
@@ -9,6 +9,58 @@ const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
|
||||
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
||||
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
|
||||
|
||||
/**
|
||||
* Maximum bytes per `process.stdout.write` call on Windows.
|
||||
*
|
||||
* Windows ConPTY ties viewport tracking to per-`WriteFile` boundaries: when a
|
||||
* single write exceeds ~32-64 KB, the pseudo-console stops following the
|
||||
* cursor and the host UI's viewport stays parked at whatever scroll position
|
||||
* the write started from. The visible symptom is that a full-paint of a long
|
||||
* session (resume, history rebuild, large permission dialog) shows only the
|
||||
* first ~30 lines until any focus event forces the host to re-query the
|
||||
* cursor. The data is delivered correctly — it's purely a viewport-sync bug.
|
||||
*
|
||||
* 8 KiB is well below the 32 KiB threshold reported on Windows Terminal and
|
||||
* leaves headroom for the other ConPTY hosts (Tabby, Hyper, VS Code) where
|
||||
* the exact limit is undocumented. The cost is a handful of extra syscalls
|
||||
* per full paint — invisible compared to the cost of the paint itself.
|
||||
*/
|
||||
const MAX_CONPTY_WRITE_CHUNK = 8 * 1024;
|
||||
|
||||
/**
|
||||
* Split `data` into chunks no larger than `maxChunkSize`, preferring a line
|
||||
* boundary (`\n`) as the cut point so escape sequences (which never contain
|
||||
* `\n`) stay intact. The TUI's full-paint buffers are line-structured
|
||||
* (`buffer += "\r\n"` between rows), so a newline almost always exists within
|
||||
* the window. The fallback for a buffer with no newline in range is a hard
|
||||
* cut at `maxChunkSize`: the ConPTY viewport bug from a single oversized
|
||||
* write is strictly worse than a one-frame escape-sequence glitch on a buffer
|
||||
* the renderer effectively never produces.
|
||||
*
|
||||
* Exported for unit testing of the chunking contract; `#safeWrite` is the
|
||||
* sole production caller.
|
||||
*/
|
||||
export function chunkForConPTY(data: string, maxChunkSize: number = MAX_CONPTY_WRITE_CHUNK): string[] {
|
||||
if (data.length <= maxChunkSize) return [data];
|
||||
const chunks: string[] = [];
|
||||
let pos = 0;
|
||||
while (pos < data.length) {
|
||||
const remaining = data.length - pos;
|
||||
if (remaining <= maxChunkSize) {
|
||||
chunks.push(data.slice(pos));
|
||||
break;
|
||||
}
|
||||
const windowEnd = pos + maxChunkSize;
|
||||
// Prefer the last newline inside the window so escape sequences stay
|
||||
// intact within their chunk; hard-cut at `windowEnd` otherwise.
|
||||
const nl = data.lastIndexOf("\n", windowEnd - 1);
|
||||
const cut = nl >= pos ? nl + 1 : windowEnd;
|
||||
chunks.push(data.slice(pos, cut));
|
||||
pos = cut;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal terminal interface for TUI
|
||||
*/
|
||||
@@ -504,11 +556,20 @@ export class ProcessTerminal implements Terminal {
|
||||
}
|
||||
|
||||
const match = sequence.match(kittyResponsePattern);
|
||||
if (match && !this.#modifyOtherKeysActive) {
|
||||
if (match) {
|
||||
if (this.#modifyOtherKeysTimeout) {
|
||||
clearTimeout(this.#modifyOtherKeysTimeout);
|
||||
this.#modifyOtherKeysTimeout = undefined;
|
||||
}
|
||||
// A DA1 sentinel that beat the kitty reply may have already
|
||||
// engaged the modifyOtherKeys fallback (terminals such as
|
||||
// Superset/xterm-on-Electron answer DA1 before `\x1b[?u`).
|
||||
// Kitty is strictly preferred — undo the fallback so the two
|
||||
// modes do not stack. See #2042.
|
||||
if (this.#modifyOtherKeysActive) {
|
||||
this.#safeWrite("\x1b[>4;0m");
|
||||
this.#modifyOtherKeysActive = false;
|
||||
}
|
||||
// Any reply to `\x1b[?u` means the terminal speaks the kitty keyboard
|
||||
// protocol. The reported flag value is the *current* stack-top — fresh
|
||||
// terminals report 0 — so support is implied by the reply itself, not by
|
||||
@@ -978,7 +1039,23 @@ export class ProcessTerminal implements Terminal {
|
||||
// files). They serve no purpose there and would surface as visible noise.
|
||||
if (!process.stdout.isTTY) return;
|
||||
try {
|
||||
process.stdout.write(data);
|
||||
// Windows ConPTY drops viewport tracking when a single write exceeds
|
||||
// ~32-64 KB: the host UI's scroll position stays parked at wherever
|
||||
// the write began, even though every byte landed in scrollback. Split
|
||||
// large paints into newline-aligned chunks so each underlying
|
||||
// `WriteFile` stays well below the threshold. The gate also covers
|
||||
// WSL — `process.platform === "linux"` there, but stdout still
|
||||
// crosses into ConPTY at the `wslhost` boundary, so the same per-
|
||||
// WriteFile cap applies. Non-ConPTY PTYs keep the single-write fast
|
||||
// path. See #2034.
|
||||
const conptyHosted = process.platform === "win32" || isWindowsSubsystemForLinux();
|
||||
if (conptyHosted && data.length > MAX_CONPTY_WRITE_CHUNK) {
|
||||
for (const chunk of chunkForConPTY(data, MAX_CONPTY_WRITE_CHUNK)) {
|
||||
process.stdout.write(chunk);
|
||||
}
|
||||
} else {
|
||||
process.stdout.write(data);
|
||||
}
|
||||
} catch (err) {
|
||||
// Any write failure means terminal is dead - no recovery possible
|
||||
this.#dead = true;
|
||||
|
||||
+101
-2
@@ -47,6 +47,12 @@ const SEGMENT_RESET = "\x1b[0m";
|
||||
const LINE_TERMINATOR = "\x1b[0m\x1b]8;;\x07";
|
||||
const ERASE_LINE = "\x1b[2K";
|
||||
const ERASE_TO_END_OF_LINE = "\x1b[K";
|
||||
// Bound the raw code-unit span handed to native width/truncation. A terminal
|
||||
// row can only display `width` cells, so oversized component rows should not
|
||||
// force proportional JS/native copies while deciding what the viewport shows.
|
||||
const LINE_FIT_MIN_SOURCE_CODE_UNITS = 4096;
|
||||
const LINE_FIT_MAX_SOURCE_CODE_UNITS = 65536;
|
||||
const LINE_FIT_SOURCE_WIDTH_MULTIPLIER = 64;
|
||||
// Hide the hardware cursor before each paint/move write. Ghostty-style bar
|
||||
// cursors can otherwise leave visual afterimages while the TUI repaints the
|
||||
// row under a visible cursor. Paint writes also disable terminal autowrap:
|
||||
@@ -2049,7 +2055,9 @@ export class TUI extends Container {
|
||||
newLines.length < this.#previousLines.length &&
|
||||
naturalViewportTop !== prevViewportTop
|
||||
) {
|
||||
return { kind: "viewportRepaint" };
|
||||
return this.#bottomAnchoredViewportUnchanged(newLines, height)
|
||||
? { kind: "deferredMutation" }
|
||||
: { kind: "viewportRepaint" };
|
||||
}
|
||||
|
||||
// Direct-input shrink can also move the natural viewport upward even when
|
||||
@@ -2437,6 +2445,17 @@ export class TUI extends Container {
|
||||
return { kind: "liveRegionPinned", appendFrom, appendTo, renderViewportTop };
|
||||
}
|
||||
|
||||
#bottomAnchoredViewportUnchanged(newLines: string[], height: number): boolean {
|
||||
const previousViewportTop = Math.max(0, this.#previousLines.length - height);
|
||||
const newViewportTop = Math.max(0, newLines.length - height);
|
||||
for (let row = 0; row < height; row++) {
|
||||
if ((newLines[newViewportTop + row] ?? "") !== (this.#previousLines[previousViewportTop + row] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
#planDeferredTailRepaint(newLines: string[], prevViewportTop: number, height: number): RenderIntent {
|
||||
const row = prevViewportTop + height - 1;
|
||||
if (row < 0 || row >= this.#previousLines.length || newLines.length !== this.#previousLines.length) {
|
||||
@@ -2478,7 +2497,8 @@ export class TUI extends Container {
|
||||
if (TERMINAL.isImageLine(raw)) {
|
||||
return { raw, width, line: raw };
|
||||
}
|
||||
const normalized = normalizeTerminalOutput(raw);
|
||||
const source = this.#lineFitSource(raw, width);
|
||||
const normalized = normalizeTerminalOutput(source);
|
||||
const asciiWidth = this.#ansiAsciiLineWidth(normalized, width);
|
||||
if ((asciiWidth ?? visibleWidth(normalized)) <= width) {
|
||||
return { raw, width, line: normalized };
|
||||
@@ -2487,6 +2507,85 @@ export class TUI extends Container {
|
||||
return { raw, width, line };
|
||||
}
|
||||
|
||||
#lineFitSource(raw: string, width: number): string {
|
||||
const safeWidth = Number.isFinite(width) ? Math.max(1, Math.trunc(width)) : 1;
|
||||
const maxSourceLength = Math.min(
|
||||
LINE_FIT_MAX_SOURCE_CODE_UNITS,
|
||||
Math.max(LINE_FIT_MIN_SOURCE_CODE_UNITS, safeWidth * LINE_FIT_SOURCE_WIDTH_MULTIPLIER),
|
||||
);
|
||||
if (raw.length <= maxSourceLength) return raw;
|
||||
|
||||
const chunks: string[] = [];
|
||||
let emitted = 0;
|
||||
for (let i = 0; i < raw.length && emitted < maxSourceLength; ) {
|
||||
if (raw.charCodeAt(i) === 0x1b) {
|
||||
const end = this.#ansiSequenceEnd(raw, i);
|
||||
if (end === -1) break;
|
||||
const sequenceLength = end - i;
|
||||
if (this.#ansiSequenceHasVisiblePayload(raw, i)) {
|
||||
// OSC 66 text-sizing spans carry their visible cells inside the
|
||||
// OSC payload. Always include the whole sequence — splitting it
|
||||
// would corrupt the terminator — and let the next loop iteration
|
||||
// terminate on the budget overflow.
|
||||
chunks.push(raw.slice(i, end));
|
||||
emitted += sequenceLength;
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if (emitted > 0 && sequenceLength <= maxSourceLength - emitted) {
|
||||
chunks.push(raw.slice(i, end));
|
||||
emitted += sequenceLength;
|
||||
}
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
|
||||
const start = i;
|
||||
const end = Math.min(raw.length, start + maxSourceLength - emitted);
|
||||
while (i < end && raw.charCodeAt(i) !== 0x1b) i++;
|
||||
if (i === start) break;
|
||||
chunks.push(raw.slice(start, i));
|
||||
emitted += i - start;
|
||||
}
|
||||
|
||||
return chunks.join("") + SEGMENT_RESET;
|
||||
}
|
||||
|
||||
#ansiSequenceEnd(line: string, start: number): number {
|
||||
const next = line.charCodeAt(start + 1);
|
||||
if (next === 0x5b) {
|
||||
let i = start + 2;
|
||||
while (i < line.length) {
|
||||
const final = line.charCodeAt(i);
|
||||
if (final >= 0x40 && final <= 0x7e) return i + 1;
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
if (next === 0x5d) {
|
||||
let i = start + 2;
|
||||
while (i < line.length) {
|
||||
const osc = line.charCodeAt(i);
|
||||
if (osc === 0x07) return i + 1;
|
||||
if (osc === 0x1b && line.charCodeAt(i + 1) === 0x5c) return i + 2;
|
||||
i++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
return start + 2 <= line.length ? start + 2 : -1;
|
||||
}
|
||||
|
||||
#ansiSequenceHasVisiblePayload(line: string, start: number): boolean {
|
||||
// OSC 66 (`\x1b]66;META;TEXT\x1b\\`) carries its visible cells inside the
|
||||
// payload, mirroring the special case in {@link #ansiAsciiLineWidth}.
|
||||
return (
|
||||
line.charCodeAt(start + 1) === 0x5d &&
|
||||
line.charCodeAt(start + 2) === 0x36 &&
|
||||
line.charCodeAt(start + 3) === 0x36 &&
|
||||
line.charCodeAt(start + 4) === 0x3b
|
||||
);
|
||||
}
|
||||
|
||||
#ansiAsciiLineWidth(line: string, maxWidth: number): number | undefined {
|
||||
let col = 0;
|
||||
for (let i = 0; i < line.length; ) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { stripVTControlCharacters } from "node:util";
|
||||
import { CURSOR_MARKER } from "@oh-my-pi/pi-tui";
|
||||
import { CombinedAutocompleteProvider } from "@oh-my-pi/pi-tui/autocomplete";
|
||||
import { Editor } from "@oh-my-pi/pi-tui/components/editor";
|
||||
import { setKittyProtocolActive } from "@oh-my-pi/pi-tui/keys";
|
||||
import { visibleWidth } from "@oh-my-pi/pi-tui/utils";
|
||||
import { setDefaultTabWidth } from "@oh-my-pi/pi-utils";
|
||||
import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings";
|
||||
@@ -616,6 +617,16 @@ describe("Editor component", () => {
|
||||
editor.setText("foo bar");
|
||||
editor.handleInput("\x1b\x7f"); // Alt+Backspace (legacy)
|
||||
expect(editor.getText()).toBe("foo ");
|
||||
|
||||
// Issue #2064: Ghostty on macOS reports Option+Backspace as `ESC [127;11u`
|
||||
// (kitty modifier 11 wire = super(8)|alt(2)). Without super support the
|
||||
// editor used to ignore this entirely and the previous word survived.
|
||||
setKittyProtocolActive(true);
|
||||
editor.setText("foo bar");
|
||||
editor.handleInput("\x1b[F"); // End — park cursor at EOL
|
||||
editor.handleInput("\x1b[127;11u"); // Ghostty Option+Backspace
|
||||
expect(editor.getText()).toBe("foo ");
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("navigates words correctly with Ctrl+Left/Right", () => {
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { chunkForConPTY, ProcessTerminal } from "@oh-my-pi/pi-tui/terminal";
|
||||
|
||||
// Regression test for https://github.com/can1357/oh-my-pi/issues/2034
|
||||
//
|
||||
// Windows ConPTY ties viewport tracking to per-`WriteFile` boundaries: when
|
||||
// a single `process.stdout.write` exceeds ~32-64 KB, the pseudo-console
|
||||
// stops following the cursor and the host UI's scroll position stays parked
|
||||
// at wherever the write began. The data lands in scrollback — Alt+Tab forces
|
||||
// the host to re-query the cursor and the viewport jumps to the bottom —
|
||||
// but until then the user sees only the first screenful of a long session
|
||||
// or resume payload.
|
||||
//
|
||||
// Fix: `ProcessTerminal#safeWrite` chunks oversized writes into ≤ 8 KiB
|
||||
// pieces on `process.platform === "win32"`. Non-win32 PTYs do not share the
|
||||
// bug and keep the single-write fast path.
|
||||
|
||||
const ESC = "\x1b";
|
||||
|
||||
function buildFullPaint(lines: number, lineLength: number): string {
|
||||
// Mirrors the shape of `TUI#emitFullPaint`'s buffer: a clear-screen prefix,
|
||||
// rows terminated with `\r\n` and a per-line SGR reset, and a cursor/end
|
||||
// sequence trailer. The exact bytes do not matter for the chunker — only
|
||||
// that escapes are present and the buffer crosses the ConPTY threshold.
|
||||
let buf = `${ESC}[2J${ESC}[H${ESC}[3J`;
|
||||
for (let i = 0; i < lines; i++) {
|
||||
if (i > 0) buf += "\r\n";
|
||||
const content = `${ESC}[38;5;${i % 256}mrow-${i.toString().padStart(4, "0")}: ${"x".repeat(lineLength)}${ESC}[0m`;
|
||||
buf += content;
|
||||
}
|
||||
buf += `${ESC}[H${ESC}[?25h`;
|
||||
return buf;
|
||||
}
|
||||
|
||||
describe("issue #2034: chunk large terminal writes on Windows ConPTY", () => {
|
||||
describe("chunkForConPTY()", () => {
|
||||
it("returns the original buffer untouched when under the chunk size", () => {
|
||||
const data = "small payload";
|
||||
expect(chunkForConPTY(data, 1024)).toEqual([data]);
|
||||
});
|
||||
|
||||
it("splits a large multi-line buffer into pieces no larger than the chunk size", () => {
|
||||
const data = buildFullPaint(2000, 60);
|
||||
const max = 8 * 1024;
|
||||
expect(data.length).toBeGreaterThan(max);
|
||||
|
||||
const chunks = chunkForConPTY(data, max);
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of chunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(max);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the full payload across chunks (no data loss or reordering)", () => {
|
||||
const data = buildFullPaint(500, 120);
|
||||
const chunks = chunkForConPTY(data, 4 * 1024);
|
||||
expect(chunks.join("")).toBe(data);
|
||||
});
|
||||
|
||||
it("splits at newline boundaries so escape sequences are never sliced apart", () => {
|
||||
// Every row is bracketed by SGR escapes. If the chunker cut inside a
|
||||
// chunk's escape sequence, the trailing chunk would not start with
|
||||
// either an escape or the post-newline state — instead it would
|
||||
// start with a stray CSI byte (`[`, digits, `m`).
|
||||
const data = buildFullPaint(400, 80);
|
||||
const chunks = chunkForConPTY(data, 4 * 1024);
|
||||
// Exclude the head chunk (starts with the clear-screen prefix).
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
// Every subsequent chunk begins on a fresh line: either the new
|
||||
// line's first byte is the SGR escape, the row's plaintext
|
||||
// prefix, or — for the trailing tail — the cursor sequence.
|
||||
const firstByte = chunk.charCodeAt(0);
|
||||
const startsWithEsc = chunk.startsWith(ESC);
|
||||
const startsWithRowText = chunk.startsWith("row-");
|
||||
expect(startsWithEsc || startsWithRowText).toBe(true);
|
||||
if (!startsWithEsc) {
|
||||
// Plain-text starts cannot be control characters that would
|
||||
// indicate a sliced escape (CSI `[`, digits, or `m`).
|
||||
expect(firstByte).toBeGreaterThanOrEqual(0x20);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("makes forward progress on a single line longer than the chunk size", () => {
|
||||
// Pathological case: one very long line with no embedded `\n`. The
|
||||
// chunker must not loop, and the joined chunks must equal the input.
|
||||
const giantLine = "a".repeat(20_000);
|
||||
const data = `${giantLine}\nshort\n`;
|
||||
const chunks = chunkForConPTY(data, 4 * 1024);
|
||||
expect(chunks.length).toBeGreaterThanOrEqual(2);
|
||||
expect(chunks.join("")).toBe(data);
|
||||
});
|
||||
|
||||
it("falls back to a raw split when the buffer contains no newlines", () => {
|
||||
const data = "x".repeat(20_000);
|
||||
const chunks = chunkForConPTY(data, 4 * 1024);
|
||||
expect(chunks.join("")).toBe(data);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
// Every chunk except possibly the tail is exactly the chunk size.
|
||||
for (const chunk of chunks.slice(0, -1)) {
|
||||
expect(chunk.length).toBe(4 * 1024);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProcessTerminal#write platform gate", () => {
|
||||
const stdinIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
||||
const stdoutIsTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const originalWslDistro = Bun.env.WSL_DISTRO_NAME;
|
||||
const originalWslInterop = Bun.env.WSL_INTEROP;
|
||||
|
||||
function setEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) delete Bun.env[key];
|
||||
else Bun.env[key] = value;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
|
||||
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
||||
// Clear WSL markers by default; tests opt in.
|
||||
setEnv("WSL_DISTRO_NAME", undefined);
|
||||
setEnv("WSL_INTEROP", undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor);
|
||||
if (stdinIsTtyDescriptor) Object.defineProperty(process.stdin, "isTTY", stdinIsTtyDescriptor);
|
||||
else Reflect.deleteProperty(process.stdin, "isTTY");
|
||||
if (stdoutIsTtyDescriptor) Object.defineProperty(process.stdout, "isTTY", stdoutIsTtyDescriptor);
|
||||
else Reflect.deleteProperty(process.stdout, "isTTY");
|
||||
setEnv("WSL_DISTRO_NAME", originalWslDistro);
|
||||
setEnv("WSL_INTEROP", originalWslInterop);
|
||||
});
|
||||
|
||||
function captureStdoutWrites(): string[] {
|
||||
const writes: string[] = [];
|
||||
vi.spyOn(process.stdout, "write").mockImplementation(chunk => {
|
||||
writes.push(typeof chunk === "string" ? chunk : chunk.toString());
|
||||
return true;
|
||||
});
|
||||
return writes;
|
||||
}
|
||||
|
||||
it("splits >8 KiB writes into chunks on win32 so ConPTY can track the viewport", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
const writes = captureStdoutWrites();
|
||||
const terminal = new ProcessTerminal();
|
||||
const payload = buildFullPaint(2000, 60);
|
||||
|
||||
terminal.write(payload);
|
||||
|
||||
const conptyChunks = writes.filter(w => w.length > 0);
|
||||
expect(conptyChunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of conptyChunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(8 * 1024);
|
||||
}
|
||||
expect(conptyChunks.join("")).toBe(payload);
|
||||
});
|
||||
|
||||
it("splits >8 KiB writes inside WSL because stdout still crosses ConPTY at wslhost", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||
setEnv("WSL_DISTRO_NAME", "Ubuntu");
|
||||
setEnv("WSL_INTEROP", "/run/WSL/123_interop");
|
||||
const writes = captureStdoutWrites();
|
||||
const terminal = new ProcessTerminal();
|
||||
const payload = buildFullPaint(2000, 60);
|
||||
|
||||
terminal.write(payload);
|
||||
|
||||
const conptyChunks = writes.filter(w => w.length > 0);
|
||||
expect(conptyChunks.length).toBeGreaterThan(1);
|
||||
for (const chunk of conptyChunks) {
|
||||
expect(chunk.length).toBeLessThanOrEqual(8 * 1024);
|
||||
}
|
||||
expect(conptyChunks.join("")).toBe(payload);
|
||||
});
|
||||
|
||||
it("keeps the single-write fast path on non-ConPTY platforms (clean linux, darwin)", () => {
|
||||
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
|
||||
const writes = captureStdoutWrites();
|
||||
const terminal = new ProcessTerminal();
|
||||
const payload = buildFullPaint(2000, 60);
|
||||
|
||||
terminal.write(payload);
|
||||
|
||||
expect(writes).toEqual([payload]);
|
||||
});
|
||||
|
||||
it("does not chunk small writes on win32", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
|
||||
const writes = captureStdoutWrites();
|
||||
const terminal = new ProcessTerminal();
|
||||
const payload = `${ESC}[H${ESC}[K`;
|
||||
|
||||
terminal.write(payload);
|
||||
|
||||
expect(writes).toEqual([payload]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { type Component, TUI } from "@oh-my-pi/pi-tui";
|
||||
import type { Terminal, TerminalAppearance } from "@oh-my-pi/pi-tui/terminal";
|
||||
|
||||
class CaptureTerminal implements Terminal {
|
||||
writes: string[] = [];
|
||||
#columns: number;
|
||||
#rows: number;
|
||||
|
||||
constructor(columns = 80, rows = 4) {
|
||||
this.#columns = columns;
|
||||
this.#rows = rows;
|
||||
}
|
||||
|
||||
get columns(): number {
|
||||
return this.#columns;
|
||||
}
|
||||
|
||||
get rows(): number {
|
||||
return this.#rows;
|
||||
}
|
||||
|
||||
get kittyProtocolActive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
get appearance(): TerminalAppearance | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
start(): void {}
|
||||
stop(): void {}
|
||||
async drainInput(): Promise<void> {}
|
||||
write(data: string): void {
|
||||
this.writes.push(data);
|
||||
}
|
||||
moveBy(): void {}
|
||||
hideCursor(): void {}
|
||||
showCursor(): void {}
|
||||
clearLine(): void {}
|
||||
clearFromCursor(): void {}
|
||||
clearScreen(): void {}
|
||||
setTitle(): void {}
|
||||
setProgress(): void {}
|
||||
onAppearanceChange(): void {}
|
||||
}
|
||||
|
||||
class RawLinesComponent implements Component {
|
||||
#lines: string[];
|
||||
|
||||
constructor(lines: string[]) {
|
||||
this.#lines = lines;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
||||
render(): string[] {
|
||||
return this.#lines;
|
||||
}
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await Bun.sleep(0);
|
||||
}
|
||||
|
||||
describe("issue #2045: renderer bounds oversized rows", () => {
|
||||
it("preserves visible text after pathological zero-width ANSI prefixes", async () => {
|
||||
const term = new CaptureTerminal(80, 4);
|
||||
const tui = new TUI(term);
|
||||
const line = `${"\x1b[31m".repeat(20_000)}payload`;
|
||||
|
||||
tui.addChild(new RawLinesComponent([line]));
|
||||
try {
|
||||
tui.start();
|
||||
await settle();
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
|
||||
const rendered = term.writes.join("");
|
||||
expect(rendered).toContain("payload");
|
||||
expect(rendered.length).toBeLessThan(12_000);
|
||||
});
|
||||
|
||||
it("preserves visible text after oversized OSC hyperlink prefixes", async () => {
|
||||
const term = new CaptureTerminal(80, 4);
|
||||
const tui = new TUI(term);
|
||||
const line = `\x1b]8;;https://example.com/${"a".repeat(70_000)}\x07link-label\x1b]8;;\x07`;
|
||||
|
||||
tui.addChild(new RawLinesComponent([line]));
|
||||
try {
|
||||
tui.start();
|
||||
await settle();
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
|
||||
const rendered = term.writes.join("");
|
||||
expect(rendered).toContain("link-label");
|
||||
expect(rendered.length).toBeLessThan(12_000);
|
||||
});
|
||||
|
||||
it("preserves OSC 66 text-sizing payloads at the start of long rows", async () => {
|
||||
const term = new CaptureTerminal(80, 4);
|
||||
const tui = new TUI(term);
|
||||
const visibleText = "H".repeat(70);
|
||||
const line = `\x1b]66;s=1;${visibleText}\x1b\\${"\x1b[31m".repeat(20_000)}`;
|
||||
|
||||
tui.addChild(new RawLinesComponent([line]));
|
||||
try {
|
||||
tui.start();
|
||||
await settle();
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
|
||||
const rendered = term.writes.join("");
|
||||
expect(rendered).toContain(visibleText);
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,19 @@ describe("matchesKey", () => {
|
||||
expect(matchesKey("\x1b[57400;133u", "1")).toBe(false);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
|
||||
it("recognises super+alt+backspace from Ghostty's default Option+Backspace wire (#2064)", () => {
|
||||
setKittyProtocolActive(true);
|
||||
// Modifier 11 (wire) = 10 (mask) = super(8)|alt(2). Ghostty's keyboard
|
||||
// inspector on macOS reports `ESC [127;11u` for Option+Backspace without
|
||||
// any user-defined keybind.
|
||||
expect(matchesKey("\x1b[127;11u", "super+alt+backspace")).toBe(true);
|
||||
expect(matchesKey("\x1b[127;11u", "alt+super+backspace")).toBe(true);
|
||||
// Plain alt+backspace must NOT consume this — the modifier truly is super|alt.
|
||||
expect(matchesKey("\x1b[127;11u", "alt+backspace")).toBe(false);
|
||||
expect(matchesKey("\x1b[127;11u", "backspace")).toBe(false);
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseKey", () => {
|
||||
@@ -132,7 +145,9 @@ describe("parseKey", () => {
|
||||
|
||||
it("ignores Kitty sequences with unsupported modifiers", () => {
|
||||
setKittyProtocolActive(true);
|
||||
expect(parseKey("\x1b[99;9u")).toBeUndefined();
|
||||
// Hyper (16) and meta (32) bits aren't surfaced because nothing binds them.
|
||||
expect(parseKey("\x1b[99;17u")).toBeUndefined(); // hyper-only
|
||||
expect(parseKey("\x1b[99;33u")).toBeUndefined(); // meta-only
|
||||
setKittyProtocolActive(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import {
|
||||
createProcessTerminalRenderHarness,
|
||||
type ProcessTerminalRenderHarness,
|
||||
} from "./process-terminal-render-harness";
|
||||
|
||||
// Progressive-enhancement probe ordering contract. omp sends `CSI ? u \\ CSI c`
|
||||
// at startup: the kitty reply (`CSI ? <flags> u`) authoritatively says the
|
||||
// terminal speaks the kitty keyboard protocol; the DA1 reply (`CSI ? ... c`)
|
||||
// is only a sentinel that guarantees a reply even from terminals that ignore
|
||||
// `CSI ? u`. Some terminals (Superset / xterm-on-Electron) answer DA1 first;
|
||||
// the kitty reply must still be honored regardless of ordering.
|
||||
describe("ProcessTerminal kitty keyboard progressive-enhancement ordering", () => {
|
||||
let harness: ProcessTerminalRenderHarness | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
harness?.dispose();
|
||||
harness = undefined;
|
||||
});
|
||||
|
||||
it("enables kitty when the kitty reply arrives before the DA1 sentinel", async () => {
|
||||
harness = createProcessTerminalRenderHarness(100, 30);
|
||||
await harness.settle();
|
||||
expect(harness.writes.join("")).toContain("\x1b[?u\x1b[c");
|
||||
harness.writes.length = 0;
|
||||
|
||||
await harness.feed("\x1b[?0u", "\x1b[?1;2c");
|
||||
|
||||
const out = harness.writes.join("");
|
||||
expect(harness.terminal.kittyProtocolActive).toBe(true);
|
||||
expect(out).toContain("\x1b[>1u");
|
||||
expect(out).not.toContain("\x1b[>4;2m");
|
||||
});
|
||||
|
||||
it("enables kitty when the DA1 sentinel arrives before the kitty reply (#2042)", async () => {
|
||||
harness = createProcessTerminalRenderHarness(100, 30);
|
||||
await harness.settle();
|
||||
harness.writes.length = 0;
|
||||
|
||||
// Superset/Electron-xterm answers DA1 before `CSI ? u`. The kitty reply
|
||||
// must override the premature modifyOtherKeys fallback.
|
||||
await harness.feed("\x1b[?1;2c", "\x1b[?0u");
|
||||
|
||||
const out = harness.writes.join("");
|
||||
expect(harness.terminal.kittyProtocolActive).toBe(true);
|
||||
expect(out).toContain("\x1b[>1u");
|
||||
const enableIdx = out.indexOf("\x1b[>4;2m");
|
||||
const disableIdx = out.indexOf("\x1b[>4;0m");
|
||||
const kittyIdx = out.indexOf("\x1b[>1u");
|
||||
expect(enableIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(disableIdx).toBeGreaterThan(enableIdx);
|
||||
expect(kittyIdx).toBeGreaterThan(enableIdx);
|
||||
});
|
||||
|
||||
it("keeps the modifyOtherKeys fallback when only DA1 ever replies", async () => {
|
||||
harness = createProcessTerminalRenderHarness(100, 30);
|
||||
await harness.settle();
|
||||
harness.writes.length = 0;
|
||||
|
||||
// Terminals that ignore `CSI ? u` answer DA1 only — modifyOtherKeys is
|
||||
// the right answer there.
|
||||
await harness.feed("\x1b[?1;2c");
|
||||
|
||||
const out = harness.writes.join("");
|
||||
expect(harness.terminal.kittyProtocolActive).toBe(false);
|
||||
expect(out).toContain("\x1b[>4;2m");
|
||||
expect(out).not.toContain("\x1b[>1u");
|
||||
});
|
||||
});
|
||||
@@ -1469,6 +1469,40 @@ describe("TUI terminal-state regressions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("tmux: offscreen shrink preserving the visible tail emits no repaint bytes", async () => {
|
||||
await withEnvPatch({ TMUX: "1", STY: undefined, ZELLIJ: undefined }, async () => {
|
||||
const term = new UnknownViewportTerminal(40, 4, 10_000);
|
||||
const tui = new TUI(term);
|
||||
const component = new MutableLinesComponent([
|
||||
"old-0",
|
||||
"remove-me",
|
||||
"old-2",
|
||||
"old-3",
|
||||
"tail-0",
|
||||
"tail-1",
|
||||
"tail-2",
|
||||
"tail-3",
|
||||
]);
|
||||
tui.addChild(component);
|
||||
|
||||
try {
|
||||
tui.start();
|
||||
await settle(term);
|
||||
expect(visible(term)).toEqual(["tail-0", "tail-1", "tail-2", "tail-3"]);
|
||||
|
||||
const writes = captureWrites(term);
|
||||
component.setLines(["old-0", "old-2", "old-3", "tail-0", "tail-1", "tail-2", "tail-3"]);
|
||||
tui.requestRender();
|
||||
await settle(term);
|
||||
|
||||
expect(visible(term)).toEqual(["tail-0", "tail-1", "tail-2", "tail-3"]);
|
||||
expect(writes).toEqual([]);
|
||||
} finally {
|
||||
tui.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Root cause family: the dirty/replay machinery assumes native scrollback
|
||||
// can be cleared and rebuilt, which is never true inside a multiplexer —
|
||||
// tmux owns pane history, reflows it on resize itself, and a "replay" can
|
||||
|
||||
Reference in New Issue
Block a user