mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1c786e70b | ||
|
|
de57f301f9 | ||
|
|
95c773ce6d | ||
|
|
c6680b3001 | ||
|
|
484e453401 | ||
|
|
4b867c2f41 | ||
|
|
368ce0339a | ||
|
|
8e63274ce6 | ||
|
|
d80c524e0e | ||
|
|
89efb007f9 | ||
|
|
407b4b68b0 | ||
|
|
637109eab0 | ||
|
|
3a0161d051 |
+7
-1
@@ -1,6 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.60-beta.2 - 2026-04-22
|
||||
## 0.1.60 - 2026-04-22
|
||||
|
||||
### Added
|
||||
- Scripts and services per worktree — define named commands in `paseo.json`, and long-running services get supervised with their own ports and nice proxy URLs like `http://web.my-app.localhost:6767`. See the [worktrees guide](https://paseo.sh/docs/worktrees).
|
||||
@@ -28,6 +28,7 @@
|
||||
- Escape reliably interrupts the active agent.
|
||||
- Checking out a pull request from a fork lands on an owner-prefixed branch so multiple forks don't collide.
|
||||
- `paseo ls` defaults to active agents; pass `-a` to include archived.
|
||||
- GitHub branch and PR picker loads faster — queries are deferred until the picker opens.
|
||||
|
||||
### Fixed
|
||||
- Composer textarea shrinks back down after sending on web.
|
||||
@@ -42,6 +43,11 @@
|
||||
- iOS image picker no longer leaves the screen unresponsive after cancelling.
|
||||
- Archiving a worktree recovers cleanly if a previous attempt was interrupted.
|
||||
- Images in agent messages with `~`-prefixed paths load instead of spinning forever.
|
||||
- Tool call blocks expand correctly on mobile while an agent is still streaming.
|
||||
- Timeline no longer stutters when catch-up and projected ranges overlap.
|
||||
- Codex no longer flashes idle when a replacement turn is in progress.
|
||||
- Branch state recovers correctly when a rebase is in progress.
|
||||
- Workspace hover card no longer clips near screen edges.
|
||||
|
||||
## 0.1.59 - 2026-04-16
|
||||
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ buildNpmPackage rec {
|
||||
|
||||
# To update: run `nix build` with lib.fakeHash, copy the `got:` hash.
|
||||
# CI auto-updates this when package-lock.json changes (see .github/workflows/).
|
||||
npmDepsHash = "sha256-5exQLdsyyKhlQzrcpBKr3xidAZ3RvQ6wvzf2XyaSPJ4=";
|
||||
npmDepsHash = "sha256-p7luf7/eeQR5seXerJd42x7gY73qqOecStNZN/3wIlk=";
|
||||
|
||||
# Prevent onnxruntime-node's install script from running during automatic
|
||||
# npm rebuild (it tries to download from api.nuget.org, which fails in the sandbox).
|
||||
|
||||
Generated
+19
-19
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"workspaces": [
|
||||
@@ -37385,16 +37385,16 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.60-beta.2",
|
||||
"@getpaseo/highlight": "0.1.60-beta.2",
|
||||
"@getpaseo/server": "0.1.60-beta.2",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.60",
|
||||
"@getpaseo/highlight": "0.1.60",
|
||||
"@getpaseo/server": "0.1.60",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -37539,11 +37539,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.60-beta.2",
|
||||
"@getpaseo/server": "0.1.60-beta.2",
|
||||
"@getpaseo/relay": "0.1.60",
|
||||
"@getpaseo/server": "0.1.60",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37584,11 +37584,11 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.60-beta.2",
|
||||
"@getpaseo/server": "0.1.60-beta.2",
|
||||
"@getpaseo/cli": "0.1.60",
|
||||
"@getpaseo/server": "0.1.60",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
@@ -37622,7 +37622,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
@@ -37823,7 +37823,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/cpp": "^1.1.5",
|
||||
@@ -37849,7 +37849,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -37865,14 +37865,14 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.60-beta.2",
|
||||
"@getpaseo/relay": "0.1.60-beta.2",
|
||||
"@getpaseo/highlight": "0.1.60",
|
||||
"@getpaseo/relay": "0.1.60",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-coding-agent": "^0.67.68",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
@@ -38627,7 +38627,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.20.3",
|
||||
"@cloudflare/workers-types": "^4.20260114.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"packages/expo-two-way-audio",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"main": "index.ts",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "APP_VARIANT=development expo start",
|
||||
@@ -32,9 +32,9 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@floating-ui/react-native": "^0.10.7",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.60-beta.2",
|
||||
"@getpaseo/highlight": "0.1.60-beta.2",
|
||||
"@getpaseo/server": "0.1.60-beta.2",
|
||||
"@getpaseo/expo-two-way-audio": "0.1.60",
|
||||
"@getpaseo/highlight": "0.1.60",
|
||||
"@getpaseo/server": "0.1.60",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@gorhom/portal": "^1.0.14",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createLocalFileAttachmentStore } from "./local-file-attachment-store";
|
||||
|
||||
const fileSystemMock = vi.hoisted(() => ({
|
||||
getInfoAsync: vi.fn(async (uri: string) => {
|
||||
if (uri.endsWith("/preview-assets/")) {
|
||||
return { exists: true, isDirectory: true };
|
||||
}
|
||||
return { exists: true, isDirectory: false, size: 4 };
|
||||
}),
|
||||
makeDirectoryAsync: vi.fn(async () => {}),
|
||||
writeAsStringAsync: vi.fn(async () => {}),
|
||||
copyAsync: vi.fn(async () => {}),
|
||||
readAsStringAsync: vi.fn(async () => "AAECAw=="),
|
||||
deleteAsync: vi.fn(async () => {}),
|
||||
readDirectoryAsync: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock("expo-file-system/legacy", () => ({
|
||||
cacheDirectory: "file:///cache/",
|
||||
EncodingType: { Base64: "base64" },
|
||||
getInfoAsync: fileSystemMock.getInfoAsync,
|
||||
makeDirectoryAsync: fileSystemMock.makeDirectoryAsync,
|
||||
writeAsStringAsync: fileSystemMock.writeAsStringAsync,
|
||||
copyAsync: fileSystemMock.copyAsync,
|
||||
readAsStringAsync: fileSystemMock.readAsStringAsync,
|
||||
deleteAsync: fileSystemMock.deleteAsync,
|
||||
readDirectoryAsync: fileSystemMock.readDirectoryAsync,
|
||||
}));
|
||||
|
||||
describe("local file attachment store", () => {
|
||||
beforeEach(() => {
|
||||
fileSystemMock.getInfoAsync.mockClear();
|
||||
fileSystemMock.makeDirectoryAsync.mockClear();
|
||||
fileSystemMock.writeAsStringAsync.mockClear();
|
||||
fileSystemMock.copyAsync.mockClear();
|
||||
fileSystemMock.readAsStringAsync.mockClear();
|
||||
fileSystemMock.deleteAsync.mockClear();
|
||||
fileSystemMock.readDirectoryAsync.mockClear();
|
||||
});
|
||||
|
||||
it("writes raw base64 sources directly to the managed file path", async () => {
|
||||
const store = createLocalFileAttachmentStore({
|
||||
storageType: "native-file",
|
||||
baseDirectoryName: "preview-assets",
|
||||
resolvePreviewUrl: async (attachment) => `file://${attachment.storageKey}`,
|
||||
});
|
||||
|
||||
const attachment = await store.save({
|
||||
id: "preview_8_test",
|
||||
mimeType: "image/png",
|
||||
fileName: "result.png",
|
||||
source: { kind: "base64", base64: "AAECAw==" },
|
||||
});
|
||||
|
||||
expect(fileSystemMock.writeAsStringAsync).toHaveBeenCalledWith(
|
||||
"file:///cache/preview-assets/preview_8_test.png",
|
||||
"AAECAw==",
|
||||
{ encoding: "base64" },
|
||||
);
|
||||
expect(attachment).toMatchObject({
|
||||
id: "preview_8_test",
|
||||
mimeType: "image/png",
|
||||
storageType: "native-file",
|
||||
storageKey: "/cache/preview-assets/preview_8_test.png",
|
||||
fileName: "result.png",
|
||||
byteSize: 4,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,13 @@ async function writeFromSource(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.source.kind === "base64") {
|
||||
await FileSystem.writeAsStringAsync(input.targetUri, input.source.base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const base64 = await blobToBase64(input.source.blob);
|
||||
await FileSystem.writeAsStringAsync(input.targetUri, base64, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AttachmentMetadata, AttachmentStore, SaveAttachmentInput } from "@/attachments/types";
|
||||
import { __setAttachmentStoreForTests } from "./store";
|
||||
import { encodeAttachmentsForSend, persistAttachmentFromBase64 } from "./service";
|
||||
|
||||
function createAttachment(input: Partial<AttachmentMetadata> = {}): AttachmentMetadata {
|
||||
return {
|
||||
id: input.id ?? "att_1",
|
||||
mimeType: input.mimeType ?? "image/png",
|
||||
storageType: input.storageType ?? "web-indexeddb",
|
||||
storageKey: input.storageKey ?? "att_1",
|
||||
fileName: input.fileName,
|
||||
byteSize: input.byteSize,
|
||||
createdAt: input.createdAt ?? 1700000000000,
|
||||
};
|
||||
}
|
||||
|
||||
function createRecordingStore(): AttachmentStore & {
|
||||
savedSources: SaveAttachmentInput[];
|
||||
releasedUrls: string[];
|
||||
} {
|
||||
const savedSources: SaveAttachmentInput[] = [];
|
||||
const releasedUrls: string[] = [];
|
||||
|
||||
return {
|
||||
storageType: "web-indexeddb",
|
||||
savedSources,
|
||||
releasedUrls,
|
||||
async save(input) {
|
||||
savedSources.push(input);
|
||||
return createAttachment({
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
fileName: input.fileName,
|
||||
byteSize: 4,
|
||||
});
|
||||
},
|
||||
async encodeBase64({ attachment }) {
|
||||
return `${attachment.id}:base64`;
|
||||
},
|
||||
async resolvePreviewUrl({ attachment }) {
|
||||
return `blob:${attachment.id}`;
|
||||
},
|
||||
async releasePreviewUrl({ url }) {
|
||||
releasedUrls.push(url);
|
||||
},
|
||||
async delete() {},
|
||||
async garbageCollect() {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("attachment service", () => {
|
||||
afterEach(() => {
|
||||
__setAttachmentStoreForTests(null);
|
||||
});
|
||||
|
||||
it("persists raw base64 without requiring a data URL wrapper", async () => {
|
||||
const store = createRecordingStore();
|
||||
__setAttachmentStoreForTests(store);
|
||||
|
||||
const attachment = await persistAttachmentFromBase64({
|
||||
id: "att_base64",
|
||||
base64: "AAECAw==",
|
||||
mimeType: "image/png",
|
||||
fileName: "image.png",
|
||||
});
|
||||
|
||||
expect(attachment).toEqual({
|
||||
id: "att_base64",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "att_1",
|
||||
fileName: "image.png",
|
||||
byteSize: 4,
|
||||
createdAt: 1700000000000,
|
||||
});
|
||||
expect(store.savedSources).toEqual([
|
||||
{
|
||||
id: "att_base64",
|
||||
mimeType: "image/png",
|
||||
fileName: "image.png",
|
||||
source: { kind: "base64", base64: "AAECAw==" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps provider send output byte-compatible", async () => {
|
||||
const store = createRecordingStore();
|
||||
__setAttachmentStoreForTests(store);
|
||||
const attachment = createAttachment({ id: "att_send", mimeType: "image/jpeg" });
|
||||
|
||||
await expect(encodeAttachmentsForSend([attachment])).resolves.toEqual([
|
||||
{ data: "att_send:base64", mimeType: "image/jpeg" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,21 @@ export async function persistAttachmentFromDataUrl(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function persistAttachmentFromBase64(input: {
|
||||
base64: string;
|
||||
mimeType?: string;
|
||||
fileName?: string | null;
|
||||
id?: string;
|
||||
}): Promise<AttachmentMetadata> {
|
||||
const store = await getAttachmentStore();
|
||||
return await store.save({
|
||||
id: input.id,
|
||||
mimeType: input.mimeType,
|
||||
fileName: input.fileName,
|
||||
source: { kind: "base64", base64: input.base64 },
|
||||
});
|
||||
}
|
||||
|
||||
export async function persistAttachmentFromFileUri(input: {
|
||||
uri: string;
|
||||
mimeType?: string;
|
||||
|
||||
@@ -23,6 +23,7 @@ export type ComposerAttachment =
|
||||
| { kind: "github_pr"; item: GitHubSearchItem };
|
||||
|
||||
export type AttachmentDataSource =
|
||||
| { kind: "base64"; base64: string }
|
||||
| { kind: "blob"; blob: Blob }
|
||||
| { kind: "data_url"; dataUrl: string }
|
||||
| { kind: "file_uri"; uri: string };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pathToFileUri } from "./utils";
|
||||
import { createImageSourceCacheKey, parseDataUrl, parseImageDataUrl, pathToFileUri } from "./utils";
|
||||
|
||||
describe("pathToFileUri", () => {
|
||||
it("converts POSIX absolute paths to file URIs", () => {
|
||||
@@ -22,3 +22,35 @@ describe("pathToFileUri", () => {
|
||||
expect(pathToFileUri("relative/path")).toBe("relative/path");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseDataUrl", () => {
|
||||
it("accepts base64 data URLs with media-type parameters", () => {
|
||||
expect(parseDataUrl("data:image/png;charset=utf-8;name=preview;base64,AAECAw==")).toEqual({
|
||||
mimeType: "image/png",
|
||||
base64: "AAECAw==",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects non-base64 data URLs", () => {
|
||||
expect(() => parseDataUrl("data:image/png,not-base64")).toThrow(
|
||||
"Attachment data URL is not base64 encoded.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseImageDataUrl", () => {
|
||||
it("returns a compact cache key for image data URLs", () => {
|
||||
const dataUrl = `data:image/png;base64,${"a".repeat(512)}`;
|
||||
|
||||
expect(parseImageDataUrl(dataUrl)).toMatchObject({
|
||||
mimeType: "image/png",
|
||||
base64: "a".repeat(512),
|
||||
});
|
||||
expect(createImageSourceCacheKey(dataUrl)).toMatch(/^data-image:image\/png:512:/);
|
||||
expect(createImageSourceCacheKey(dataUrl)).not.toContain("a".repeat(128));
|
||||
});
|
||||
|
||||
it("ignores non-image data URLs", () => {
|
||||
expect(parseImageDataUrl("data:text/plain;base64,SGVsbG8=")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,11 +14,17 @@ export function normalizeMimeType(input: string | undefined | null): string {
|
||||
}
|
||||
|
||||
export function parseDataUrl(dataUrl: string): { mimeType: string; base64: string } {
|
||||
const match = /^data:([^;,]+)?;base64,(.+)$/i.exec(dataUrl);
|
||||
const match = /^data:([^,]*),([\s\S]+)$/i.exec(dataUrl.trim());
|
||||
if (!match) {
|
||||
throw new Error("Malformed data URL for attachment.");
|
||||
}
|
||||
const [, mimeTypeRaw, base64] = match;
|
||||
const metadata = match[1] ?? "";
|
||||
const base64 = match[2]?.replace(/\s/g, "");
|
||||
const [mimeTypeRaw, ...parameters] = metadata.split(";").map((part) => part.trim());
|
||||
const isBase64 = parameters.some((part) => part.toLowerCase() === "base64");
|
||||
if (!isBase64) {
|
||||
throw new Error("Attachment data URL is not base64 encoded.");
|
||||
}
|
||||
if (!base64) {
|
||||
throw new Error("Attachment data URL is missing base64 payload.");
|
||||
}
|
||||
@@ -28,6 +34,61 @@ export function parseDataUrl(dataUrl: string): { mimeType: string; base64: strin
|
||||
};
|
||||
}
|
||||
|
||||
function hashString(value: string): string {
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
export function parseImageDataUrl(
|
||||
uri: string,
|
||||
): { mimeType: string; base64: string; cacheKey: string } | null {
|
||||
if (!uri.trim().toLowerCase().startsWith("data:image/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseDataUrl(uri);
|
||||
if (!parsed.mimeType.toLowerCase().startsWith("image/")) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
cacheKey: `data-image:${parsed.mimeType}:${parsed.base64.length}:${hashString(parsed.base64)}`,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createImageSourceCacheKey(source: string): string {
|
||||
return parseImageDataUrl(source)?.cacheKey ?? source;
|
||||
}
|
||||
|
||||
export function getFileNameFromPath(path: string | null | undefined): string | null {
|
||||
const trimmed = path?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = trimmed.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const fileName = normalized.split("/").pop()?.trim();
|
||||
return fileName || null;
|
||||
}
|
||||
|
||||
export function createPreviewAttachmentId(input: {
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
path?: string | null;
|
||||
}): string {
|
||||
const path = input.path?.trim() ?? "";
|
||||
const hash = hashString(`${input.mimeType}\0${path}\0${input.base64}`);
|
||||
return `preview_${input.base64.length}_${hash}`;
|
||||
}
|
||||
|
||||
export async function blobToBase64(blob: Blob): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
@@ -74,6 +74,15 @@ function runTx<T>(
|
||||
});
|
||||
}
|
||||
|
||||
function base64ToBlob(input: { base64: string; mimeType: string }): Blob {
|
||||
const binary = atob(input.base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new Blob([bytes], { type: input.mimeType });
|
||||
}
|
||||
|
||||
async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; mimeType: string }> {
|
||||
const source = input.source;
|
||||
if (source.kind === "blob") {
|
||||
@@ -96,6 +105,14 @@ async function sourceToBlob(input: SaveAttachmentInput): Promise<{ blob: Blob; m
|
||||
};
|
||||
}
|
||||
|
||||
if (source.kind === "base64") {
|
||||
const mimeType = normalizeMimeType(input.mimeType);
|
||||
return {
|
||||
blob: base64ToBlob({ base64: source.base64, mimeType }),
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(source.uri);
|
||||
const blob = await response.blob();
|
||||
const mimeType = normalizeMimeType(input.mimeType ?? blob.type);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
|
||||
interface AgentListProps {
|
||||
agents: AggregatedAgent[];
|
||||
@@ -280,6 +281,7 @@ export function AgentList({
|
||||
const insets = useSafeAreaInsets();
|
||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
|
||||
const actionClient = useSessionStore((state) =>
|
||||
actionAgent?.serverId ? (state.sessions[actionAgent.serverId]?.client ?? null) : null,
|
||||
@@ -320,20 +322,23 @@ export function AgentList({
|
||||
[isActionSheetVisible, onAgentSelect],
|
||||
);
|
||||
|
||||
const handleAgentLongPress = useCallback((agent: AggregatedAgent) => {
|
||||
const isRunning = agent.status === "running" || agent.status === "initializing";
|
||||
if (isRunning) {
|
||||
setActionAgent(agent);
|
||||
return;
|
||||
}
|
||||
const handleAgentLongPress = useCallback(
|
||||
(agent: AggregatedAgent) => {
|
||||
const isRunning = agent.status === "running" || agent.status === "initializing";
|
||||
if (isRunning) {
|
||||
setActionAgent(agent);
|
||||
return;
|
||||
}
|
||||
|
||||
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
|
||||
if (!client) {
|
||||
setActionAgent(agent);
|
||||
return;
|
||||
}
|
||||
void client.archiveAgent(agent.id);
|
||||
}, []);
|
||||
const client = useSessionStore.getState().sessions[agent.serverId]?.client ?? null;
|
||||
if (!client) {
|
||||
setActionAgent(agent);
|
||||
return;
|
||||
}
|
||||
void archiveAgent({ serverId: agent.serverId, agentId: agent.id }).catch(() => {});
|
||||
},
|
||||
[archiveAgent],
|
||||
);
|
||||
|
||||
const handleCloseActionSheet = useCallback(() => {
|
||||
setActionAgent(null);
|
||||
@@ -344,9 +349,9 @@ export function AgentList({
|
||||
return;
|
||||
}
|
||||
// Timeout errors are swallowed — the daemon will still process the archive
|
||||
void actionClient.archiveAgent(actionAgent.id).catch(() => {});
|
||||
void archiveAgent({ serverId: actionAgent.serverId, agentId: actionAgent.id }).catch(() => {});
|
||||
setActionAgent(null);
|
||||
}, [actionAgent, actionClient]);
|
||||
}, [actionAgent, actionClient, archiveAgent]);
|
||||
|
||||
const flatItems = useMemo((): FlatListItem[] => {
|
||||
const order = ["Today", "Yesterday", "This week", "This month", "Older"] as const;
|
||||
|
||||
@@ -344,7 +344,7 @@ vi.mock("@/contexts/toast-context", () => ({
|
||||
useToast: () => ({ error: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/scroll-jank-investigation", () => ({
|
||||
vi.mock("@/utils/scroll-jank", () => ({
|
||||
markScrollInvestigationRender: markScrollInvestigationRenderMock,
|
||||
markScrollInvestigationEvent: vi.fn(),
|
||||
}));
|
||||
@@ -834,6 +834,23 @@ describe("Composer attachments", () => {
|
||||
expect(combobox.dataset.anchor).toBe("attached");
|
||||
});
|
||||
|
||||
it("lazily searches GitHub only after the GitHub combobox opens", async () => {
|
||||
renderComposer();
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(mockClient.searchGitHub).not.toHaveBeenCalled();
|
||||
|
||||
click(queryByTestId("message-input-attach-button")!);
|
||||
click(queryByTestId("message-input-attachment-menu-item-github")!);
|
||||
await findByTestId("composer-github-combobox");
|
||||
|
||||
expect(mockClient.searchGitHub).toHaveBeenCalledWith({
|
||||
cwd: "/repo",
|
||||
query: "",
|
||||
limit: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the GitHub combobox after selecting an item", async () => {
|
||||
renderComposer();
|
||||
|
||||
|
||||
@@ -790,7 +790,7 @@ export function Composer({
|
||||
limit: 20,
|
||||
});
|
||||
},
|
||||
enabled: isConnected && !!client && cwd.trim().length > 0,
|
||||
enabled: isGithubPickerOpen && isConnected && !!client && cwd.trim().length > 0,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ import { lineNumberGutterWidth } from "@/components/code-insets";
|
||||
import { isRenderedMarkdownFile } from "@/components/file-pane-render-mode";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBase64 } from "@/attachments/service";
|
||||
import { createPreviewAttachmentId, getFileNameFromPath } from "@/attachments/utils";
|
||||
|
||||
interface CodeLineProps {
|
||||
tokens: HighlightToken[];
|
||||
@@ -40,6 +44,7 @@ interface FilePreviewBodyProps {
|
||||
showDesktopWebScrollbar: boolean;
|
||||
isMobile: boolean;
|
||||
filePath: string;
|
||||
imagePreviewUri: string | null;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
@@ -60,6 +65,32 @@ function formatFileSize({ size }: { size: number }): string {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
async function createFilePanePreview(file: ExplorerFile | null): Promise<{
|
||||
file: ExplorerFile | null;
|
||||
imageAttachment: AttachmentMetadata | null;
|
||||
}> {
|
||||
if (!file || file.kind !== "image" || !file.content) {
|
||||
return { file, imageAttachment: null };
|
||||
}
|
||||
|
||||
const { content: _content, ...imageFile } = file;
|
||||
const imageAttachment = await persistAttachmentFromBase64({
|
||||
id: createPreviewAttachmentId({
|
||||
base64: file.content,
|
||||
mimeType: file.mimeType ?? "image/png",
|
||||
path: file.path,
|
||||
}),
|
||||
base64: file.content,
|
||||
mimeType: file.mimeType,
|
||||
fileName: getFileNameFromPath(file.path),
|
||||
});
|
||||
|
||||
return {
|
||||
file: imageFile,
|
||||
imageAttachment,
|
||||
};
|
||||
}
|
||||
|
||||
const CodeLine = React.memo(function CodeLine({
|
||||
tokens,
|
||||
lineNumber,
|
||||
@@ -116,6 +147,7 @@ function FilePreviewBody({
|
||||
showDesktopWebScrollbar,
|
||||
isMobile,
|
||||
filePath,
|
||||
imagePreviewUri,
|
||||
}: FilePreviewBodyProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isDark = theme.colorScheme === "dark";
|
||||
@@ -230,7 +262,16 @@ function FilePreviewBody({
|
||||
);
|
||||
}
|
||||
|
||||
if (preview.kind === "image" && preview.content) {
|
||||
if (preview.kind === "image") {
|
||||
if (!imagePreviewUri) {
|
||||
return (
|
||||
<View style={styles.centerState}>
|
||||
<ActivityIndicator size="small" />
|
||||
<Text style={styles.loadingText}>Loading file…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.previewScrollContainer}>
|
||||
<RNScrollView
|
||||
@@ -245,7 +286,7 @@ function FilePreviewBody({
|
||||
>
|
||||
<RNImage
|
||||
source={{
|
||||
uri: `data:${preview.mimeType ?? "image/png"};base64,${preview.content}`,
|
||||
uri: imagePreviewUri,
|
||||
}}
|
||||
style={styles.previewImage}
|
||||
resizeMode="contain"
|
||||
@@ -292,11 +333,17 @@ export function FilePane({
|
||||
normalizedFilePath,
|
||||
"file",
|
||||
);
|
||||
return { file: payload.file ?? null, error: payload.error ?? null };
|
||||
const preview = await createFilePanePreview(payload.file ?? null);
|
||||
return {
|
||||
file: preview.file,
|
||||
imageAttachment: preview.imageAttachment,
|
||||
error: payload.error ?? null,
|
||||
};
|
||||
},
|
||||
staleTime: 5_000,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
const imagePreviewUri = useAttachmentPreviewUrl(query.data?.imageAttachment ?? null);
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="workspace-file-pane">
|
||||
@@ -312,6 +359,7 @@ export function FilePane({
|
||||
showDesktopWebScrollbar={showDesktopWebScrollbar}
|
||||
isMobile={isMobile}
|
||||
filePath={filePath}
|
||||
imagePreviewUri={imagePreviewUri}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -78,11 +78,17 @@ import {
|
||||
setAssistantImageMetadata,
|
||||
} from "@/utils/assistant-image-metadata";
|
||||
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||
import {
|
||||
createPreviewAttachmentId,
|
||||
getFileNameFromPath,
|
||||
parseImageDataUrl,
|
||||
} from "@/attachments/utils";
|
||||
export type { InlinePathTarget } from "@/utils/inline-path";
|
||||
import { PlanCard } from "./plan-card";
|
||||
import { useToolCallSheet } from "./tool-call-sheet";
|
||||
import { ToolCallDetailsContent } from "./tool-call-details";
|
||||
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
|
||||
import { persistAttachmentFromBase64, persistAttachmentFromDataUrl } from "@/attachments/service";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
@@ -595,6 +601,7 @@ function AssistantMarkdownImage({
|
||||
() => resolveAssistantImageSource({ source, workspaceRoot }),
|
||||
[source, workspaceRoot],
|
||||
);
|
||||
const dataImage = useMemo(() => parseImageDataUrl(source), [source]);
|
||||
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
||||
() => ({
|
||||
marginTop: hasLeadingContent ? theme.spacing[4] : 0,
|
||||
@@ -621,16 +628,44 @@ function AssistantMarkdownImage({
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
if (!payload.file || payload.file.kind !== "image" || !payload.file.content) {
|
||||
const file = payload.file;
|
||||
if (!file || file.kind !== "image" || !file.content) {
|
||||
throw new Error("Image preview unavailable.");
|
||||
}
|
||||
|
||||
return `data:${payload.file.mimeType ?? "image/png"};base64,${payload.file.content}`;
|
||||
return await persistAttachmentFromBase64({
|
||||
id: createPreviewAttachmentId({
|
||||
base64: file.content,
|
||||
mimeType: file.mimeType ?? "image/png",
|
||||
path: file.path || resolution.path,
|
||||
}),
|
||||
base64: file.content,
|
||||
mimeType: file.mimeType,
|
||||
fileName: getFileNameFromPath(file.path || resolution.path),
|
||||
});
|
||||
},
|
||||
});
|
||||
const dataImageQuery = useQuery({
|
||||
queryKey: ["assistantMarkdownDataImage", dataImage?.cacheKey ?? null],
|
||||
enabled: dataImage !== null,
|
||||
staleTime: 30_000,
|
||||
queryFn: async () => {
|
||||
if (!dataImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await persistAttachmentFromDataUrl({
|
||||
id: createPreviewAttachmentId(dataImage),
|
||||
dataUrl: source,
|
||||
mimeType: dataImage.mimeType,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const directUri = resolution?.kind === "direct" ? resolution.uri : null;
|
||||
const resolvedUri = directUri ?? query.data ?? null;
|
||||
const fileAssetUri = useAttachmentPreviewUrl(query.data);
|
||||
const dataImageAssetUri = useAttachmentPreviewUrl(dataImageQuery.data);
|
||||
const directUri = resolution?.kind === "direct" && !dataImage ? resolution.uri : null;
|
||||
const resolvedUri = directUri ?? dataImageAssetUri ?? fileAssetUri ?? null;
|
||||
|
||||
if (resolvedUri) {
|
||||
return (
|
||||
@@ -645,7 +680,7 @@ function AssistantMarkdownImage({
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isLoading) {
|
||||
if (query.isLoading || dataImageQuery.isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
@@ -668,7 +703,11 @@ function AssistantMarkdownImage({
|
||||
]}
|
||||
>
|
||||
<Text style={assistantMessageStylesheet.imageErrorText}>
|
||||
{query.error instanceof Error ? query.error.message : "Unable to load image preview."}
|
||||
{query.error instanceof Error
|
||||
? query.error.message
|
||||
: dataImageQuery.error instanceof Error
|
||||
? dataImageQuery.error.message
|
||||
: "Unable to load image preview."}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
@@ -1033,9 +1072,10 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer(
|
||||
return (
|
||||
<View style={expandableBadgeStylesheet.shimmerOverlay} pointerEvents="none">
|
||||
<MaskedView
|
||||
pointerEvents="none"
|
||||
style={nativeShimmerTrackStyle}
|
||||
maskElement={
|
||||
<View style={nativeShimmerMaskStyle}>
|
||||
<View pointerEvents="none" style={nativeShimmerMaskStyle}>
|
||||
<Text style={nativeLabelMaskStyle} numberOfLines={1}>
|
||||
{label}
|
||||
</Text>
|
||||
@@ -1049,8 +1089,8 @@ const NativeExpandableBadgeShimmer = memo(function NativeExpandableBadgeShimmer(
|
||||
</View>
|
||||
}
|
||||
>
|
||||
<View style={nativeShimmerTrackStyle}>
|
||||
<Animated.View style={nativeShimmerPeakCombinedStyle}>
|
||||
<View pointerEvents="none" style={nativeShimmerTrackStyle}>
|
||||
<Animated.View pointerEvents="none" style={nativeShimmerPeakCombinedStyle}>
|
||||
<Svg width="100%" height="100%" preserveAspectRatio="none">
|
||||
<Defs>
|
||||
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
@@ -2105,10 +2145,9 @@ function areExpandableBadgePropsEqual(previous: ExpandableBadgeProps, next: Expa
|
||||
if (previous.isLastInSequence !== next.isLastInSequence) return false;
|
||||
if (previous.disableOuterSpacing !== next.disableOuterSpacing) return false;
|
||||
if (previous.testID !== next.testID) return false;
|
||||
if (Boolean(previous.onToggle) !== Boolean(next.onToggle)) return false;
|
||||
if (previous.isExpanded && previous.renderDetails !== next.renderDetails) {
|
||||
return false;
|
||||
}
|
||||
if (previous.onToggle !== next.onToggle) return false;
|
||||
if (previous.onDetailHoverChange !== next.onDetailHoverChange) return false;
|
||||
if (previous.renderDetails !== next.renderDetails) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,17 @@ const turnFailedEvent: AgentStreamEventPayload = {
|
||||
error: "failed",
|
||||
};
|
||||
|
||||
const turnCanceledEvent: AgentStreamEventPayload = {
|
||||
type: "turn_canceled",
|
||||
provider: "codex",
|
||||
reason: "interrupted",
|
||||
};
|
||||
|
||||
describe("session stream lifecycle helpers", () => {
|
||||
it("derives optimistic terminal lifecycle only when current status is running", () => {
|
||||
expect(deriveOptimisticLifecycleStatus("running", turnCompletedEvent)).toBe("idle");
|
||||
expect(deriveOptimisticLifecycleStatus("running", turnFailedEvent)).toBe("error");
|
||||
expect(deriveOptimisticLifecycleStatus("running", turnCanceledEvent)).toBe(null);
|
||||
expect(deriveOptimisticLifecycleStatus("initializing", turnCompletedEvent)).toBe(null);
|
||||
expect(deriveOptimisticLifecycleStatus("idle", turnFailedEvent)).toBe(null);
|
||||
});
|
||||
|
||||
@@ -10,10 +10,13 @@ export function deriveOptimisticLifecycleStatus(
|
||||
}
|
||||
switch (event.type) {
|
||||
case "turn_completed":
|
||||
case "turn_canceled":
|
||||
return "idle";
|
||||
case "turn_failed":
|
||||
return "error";
|
||||
case "turn_canceled":
|
||||
// A canceled turn can be either a final user cancel or an interrupt before
|
||||
// a replacement turn starts. The daemon snapshot is authoritative here.
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
|
||||
return {
|
||||
seqStart: seq,
|
||||
seqEnd: seq,
|
||||
provider: "claude",
|
||||
item: { type, text },
|
||||
timestamp: new Date(1000 + seq).toISOString(),
|
||||
@@ -318,6 +319,39 @@ describe("processTimelineResponse", () => {
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("requests canonical catch-up when a projected entry overlaps unseen seqs", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(4, "merged assistant message"),
|
||||
seqEnd: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.sideEffects).toContainEqual({
|
||||
type: "catch_up",
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
endSeq: 5,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("drops entries with epoch mismatch", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
@@ -602,6 +636,28 @@ describe("processAgentStreamEvent", () => {
|
||||
expect(result.agent!.status).toBe("error");
|
||||
});
|
||||
|
||||
it("does not derive optimistic idle status on turn_canceled for running agent", () => {
|
||||
const turnCanceledEvent: AgentStreamEventPayload = {
|
||||
type: "turn_canceled",
|
||||
provider: "codex",
|
||||
reason: "interrupted",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCanceledEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
|
||||
it("does not change agent when status is not running", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
|
||||
@@ -53,6 +53,7 @@ type InitRequestDirection = "tail" | "after";
|
||||
|
||||
type TimelineResponseEntry = {
|
||||
seqStart: number;
|
||||
seqEnd: number;
|
||||
provider: string;
|
||||
item: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
@@ -122,6 +123,7 @@ export function processTimelineResponse(
|
||||
// ------------------------------------------------------------------
|
||||
const timelineUnits = payload.entries.map((entry) => ({
|
||||
seq: entry.seqStart,
|
||||
seqEnd: entry.seqEnd,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
@@ -200,7 +202,14 @@ export function processTimelineResponse(
|
||||
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
|
||||
break;
|
||||
}
|
||||
if (decision === "drop_stale" || decision === "drop_epoch") {
|
||||
if (decision === "drop_stale") {
|
||||
if (cursor && unit.seqEnd > cursor.endSeq) {
|
||||
gapCursor = { epoch: cursor.epoch, endSeq: cursor.endSeq };
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (decision === "drop_epoch") {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -209,7 +218,7 @@ export function processTimelineResponse(
|
||||
cursor = {
|
||||
epoch: payload.epoch,
|
||||
startSeq: unit.seq,
|
||||
endSeq: unit.seq,
|
||||
endSeq: unit.seqEnd,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
@@ -218,7 +227,7 @@ export function processTimelineResponse(
|
||||
}
|
||||
cursor = {
|
||||
...cursor,
|
||||
endSeq: unit.seq,
|
||||
endSeq: unit.seqEnd,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,33 @@ describe("desktop attachment store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("saves raw base64 sources via desktop filesystem writes", async () => {
|
||||
const store = createDesktopAttachmentStore();
|
||||
const attachment = await store.save({
|
||||
id: "att_base64",
|
||||
mimeType: "image/png",
|
||||
fileName: "inline.png",
|
||||
source: {
|
||||
kind: "base64",
|
||||
base64: "AAECAw==",
|
||||
},
|
||||
});
|
||||
|
||||
expect(writeDesktopAttachmentBase64Mock).toHaveBeenCalledWith({
|
||||
attachmentId: "att_base64",
|
||||
base64: "AAECAw==",
|
||||
extension: ".png",
|
||||
});
|
||||
expect(attachment).toMatchObject({
|
||||
id: "att_base64",
|
||||
mimeType: "image/png",
|
||||
storageType: "desktop-file",
|
||||
storageKey: "/managed/att_2.png",
|
||||
fileName: "inline.png",
|
||||
byteSize: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates encode/preview/delete/gc to desktop command path", async () => {
|
||||
const store = createDesktopAttachmentStore();
|
||||
const attachment = {
|
||||
|
||||
@@ -166,6 +166,16 @@ export function createDesktopAttachmentStore(): AttachmentStore {
|
||||
});
|
||||
}
|
||||
|
||||
if (input.source.kind === "base64") {
|
||||
const mimeType = normalizeMimeType(input.mimeType);
|
||||
return await saveDesktopAttachmentFromBase64({
|
||||
id,
|
||||
base64: input.source.base64,
|
||||
mimeType,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
|
||||
const mimeType = normalizeMimeType(input.mimeType ?? input.source.blob.type);
|
||||
const base64 = await blobToBase64(input.source.blob);
|
||||
return await saveDesktopAttachmentFromBase64({
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function agentHistoryQueryKey(serverId: string | null) {
|
||||
return ["agentHistory", serverId] as const;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useCallback, useMemo } from "react";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
|
||||
import { agentHistoryQueryKey } from "./agent-history-query-key";
|
||||
|
||||
const AGENT_HISTORY_PAGE_LIMIT = 200;
|
||||
const AGENT_HISTORY_SORT: NonNullable<FetchAgentHistoryOptions["sort"]> = [
|
||||
@@ -30,10 +31,6 @@ type AgentHistoryPage = {
|
||||
pageInfo: FetchAgentHistoryPageInfo;
|
||||
};
|
||||
|
||||
export function agentHistoryQueryKey(serverId: string | null) {
|
||||
return ["agentHistory", serverId] as const;
|
||||
}
|
||||
|
||||
async function fetchAgentHistoryPage(input: {
|
||||
client: DaemonClient;
|
||||
serverId: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { agentHistoryQueryKey } from "./agent-history-query-key";
|
||||
import { __private__, applyArchivedAgentCloseResults } from "./use-archive-agent";
|
||||
|
||||
function makeAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
@@ -112,6 +113,17 @@ describe("useArchiveAgent", () => {
|
||||
queryClient.setQueryData(["allAgents", "server-a"], {
|
||||
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
||||
});
|
||||
queryClient.setQueryData(agentHistoryQueryKey("server-a"), {
|
||||
pages: [
|
||||
{
|
||||
agents: [
|
||||
{ id: "agent-1", archivedAt: null },
|
||||
{ id: "agent-2", archivedAt: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
|
||||
applyArchivedAgentCloseResults({
|
||||
queryClient,
|
||||
@@ -131,6 +143,17 @@ describe("useArchiveAgent", () => {
|
||||
expect(queryClient.getQueryData(["allAgents", "server-a"])).toEqual({
|
||||
entries: [{ agent: { id: "agent-2" } }],
|
||||
});
|
||||
expect(queryClient.getQueryData(agentHistoryQueryKey("server-a"))).toEqual({
|
||||
pages: [
|
||||
{
|
||||
agents: [
|
||||
{ id: "agent-1", archivedAt: new Date("2026-04-01T04:00:00.000Z") },
|
||||
{ id: "agent-2", archivedAt: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
});
|
||||
|
||||
it("can apply archived agent close results without invalidating cached lists", () => {
|
||||
@@ -143,6 +166,14 @@ describe("useArchiveAgent", () => {
|
||||
queryClient.setQueryData(["allAgents", "server-a"], {
|
||||
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
||||
});
|
||||
queryClient.setQueryData(agentHistoryQueryKey("server-a"), {
|
||||
pages: [
|
||||
{
|
||||
agents: [{ id: "agent-1", archivedAt: null }],
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
|
||||
applyArchivedAgentCloseResults({
|
||||
queryClient,
|
||||
@@ -153,5 +184,14 @@ describe("useArchiveAgent", () => {
|
||||
|
||||
expect(queryClient.getQueryState(["sidebarAgentsList", "server-a"])?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(["allAgents", "server-a"])?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(agentHistoryQueryKey("server-a"))?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryData(agentHistoryQueryKey("server-a"))).toEqual({
|
||||
pages: [
|
||||
{
|
||||
agents: [{ id: "agent-1", archivedAt: new Date("2026-04-01T04:00:00.000Z") }],
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { agentHistoryQueryKey } from "./agent-history-query-key";
|
||||
|
||||
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
|
||||
|
||||
@@ -32,6 +33,7 @@ interface AgentsListQueryData {
|
||||
interface ArchivedAgentListCacheSnapshot {
|
||||
sidebarAgentsList: AgentsListQueryData | undefined;
|
||||
allAgents: AgentsListQueryData | undefined;
|
||||
agentHistory: AgentHistoryQueryData | undefined;
|
||||
}
|
||||
|
||||
interface ArchiveAgentMutationContext {
|
||||
@@ -39,6 +41,19 @@ interface ArchiveAgentMutationContext {
|
||||
lists: ArchivedAgentListCacheSnapshot;
|
||||
}
|
||||
|
||||
interface AgentHistoryQueryAgent {
|
||||
id?: string | null;
|
||||
archivedAt?: Date | null;
|
||||
}
|
||||
|
||||
interface AgentHistoryQueryPage {
|
||||
agents?: AgentHistoryQueryAgent[];
|
||||
}
|
||||
|
||||
interface AgentHistoryQueryData {
|
||||
pages?: AgentHistoryQueryPage[];
|
||||
}
|
||||
|
||||
function toArchiveKey(input: ArchiveAgentInput): string {
|
||||
const serverId = input.serverId.trim();
|
||||
const agentId = input.agentId.trim();
|
||||
@@ -121,6 +136,54 @@ function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgen
|
||||
);
|
||||
}
|
||||
|
||||
function markAgentArchivedInHistoryPayload<T extends AgentHistoryQueryData | undefined>(
|
||||
payload: T,
|
||||
input: ArchiveAgentInput & { archivedAt: string },
|
||||
): T {
|
||||
if (!payload || !Array.isArray(payload.pages) || !input.agentId) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
const archivedAt = new Date(input.archivedAt);
|
||||
if (Number.isNaN(archivedAt.getTime())) {
|
||||
return payload;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const pages = payload.pages.map((page) => {
|
||||
if (!Array.isArray(page.agents)) {
|
||||
return page;
|
||||
}
|
||||
|
||||
let pageChanged = false;
|
||||
const agents = page.agents.map((agent) => {
|
||||
if (agent.id !== input.agentId) {
|
||||
return agent;
|
||||
}
|
||||
pageChanged = true;
|
||||
changed = true;
|
||||
return {
|
||||
...agent,
|
||||
archivedAt,
|
||||
};
|
||||
});
|
||||
|
||||
return pageChanged ? { ...page, agents } : page;
|
||||
});
|
||||
|
||||
return changed ? ({ ...payload, pages } as T) : payload;
|
||||
}
|
||||
|
||||
function markAgentArchivedInHistoryCache(
|
||||
queryClient: QueryClient,
|
||||
input: ArchiveAgentInput & { archivedAt: string },
|
||||
): void {
|
||||
queryClient.setQueryData<AgentHistoryQueryData | undefined>(
|
||||
agentHistoryQueryKey(input.serverId),
|
||||
(current) => markAgentArchivedInHistoryPayload(current, input),
|
||||
);
|
||||
}
|
||||
|
||||
function getStoredAgentSnapshot(input: ArchiveAgentInput) {
|
||||
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
|
||||
}
|
||||
@@ -161,13 +224,16 @@ function getArchivedAgentListCacheSnapshot(
|
||||
serverId,
|
||||
]),
|
||||
allAgents: queryClient.getQueryData<AgentsListQueryData | undefined>(["allAgents", serverId]),
|
||||
agentHistory: queryClient.getQueryData<AgentHistoryQueryData | undefined>(
|
||||
agentHistoryQueryKey(serverId),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function restoreCachedListSnapshot(
|
||||
function restoreCachedQuerySnapshot<T>(
|
||||
queryClient: QueryClient,
|
||||
queryKey: readonly [string, string],
|
||||
snapshot: AgentsListQueryData | undefined,
|
||||
queryKey: readonly unknown[],
|
||||
snapshot: T | undefined,
|
||||
): void {
|
||||
if (snapshot === undefined) {
|
||||
queryClient.removeQueries({ queryKey, exact: true });
|
||||
@@ -181,12 +247,13 @@ function restoreArchivedAgentListCacheSnapshot(
|
||||
serverId: string,
|
||||
snapshot: ArchivedAgentListCacheSnapshot,
|
||||
): void {
|
||||
restoreCachedListSnapshot(
|
||||
restoreCachedQuerySnapshot(
|
||||
queryClient,
|
||||
["sidebarAgentsList", serverId],
|
||||
snapshot.sidebarAgentsList,
|
||||
);
|
||||
restoreCachedListSnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
|
||||
restoreCachedQuerySnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
|
||||
restoreCachedQuerySnapshot(queryClient, agentHistoryQueryKey(serverId), snapshot.agentHistory);
|
||||
}
|
||||
|
||||
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
|
||||
@@ -235,6 +302,11 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
|
||||
serverId: input.serverId,
|
||||
agentId: result.agentId,
|
||||
});
|
||||
markAgentArchivedInHistoryCache(input.queryClient, {
|
||||
serverId: input.serverId,
|
||||
agentId: result.agentId,
|
||||
archivedAt: result.archivedAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.invalidateQueries ?? true) {
|
||||
@@ -244,6 +316,9 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: ["allAgents", input.serverId],
|
||||
});
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: agentHistoryQueryKey(input.serverId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,6 +399,9 @@ export function useArchiveAgent() {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["allAgents", input.serverId],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: agentHistoryQueryKey(input.serverId),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -359,4 +437,5 @@ export const __private__ = {
|
||||
setAgentArchiving,
|
||||
isAgentArchiving,
|
||||
removeAgentFromListPayload,
|
||||
markAgentArchivedInHistoryPayload,
|
||||
};
|
||||
|
||||
@@ -111,13 +111,29 @@ describe("useHoverSafeZone", () => {
|
||||
});
|
||||
|
||||
act(() => pointerMove(110, 40));
|
||||
expect(onEnterSafeZone).toHaveBeenCalledTimes(1);
|
||||
expect(onLeaveSafeZone).not.toHaveBeenCalled();
|
||||
|
||||
act(() => pointerMove(300, 40));
|
||||
expect(onLeaveSafeZone).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => pointerMove(130, 40));
|
||||
expect(onEnterSafeZone).toHaveBeenCalledTimes(1);
|
||||
expect(onEnterSafeZone).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("refreshes the safe-zone enter callback while moving inside", () => {
|
||||
const onEnterSafeZone = vi.fn();
|
||||
const onLeaveSafeZone = vi.fn();
|
||||
|
||||
act(() => {
|
||||
root?.render(<Harness onEnterSafeZone={onEnterSafeZone} onLeaveSafeZone={onLeaveSafeZone} />);
|
||||
});
|
||||
|
||||
act(() => pointerMove(110, 40));
|
||||
act(() => pointerMove(130, 40));
|
||||
|
||||
expect(onEnterSafeZone).toHaveBeenCalledTimes(2);
|
||||
expect(onLeaveSafeZone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats leaving the browser window as leaving the safe zone", () => {
|
||||
|
||||
@@ -54,8 +54,15 @@ export function useHoverSafeZone({
|
||||
const contentRect = contentNode ? contentNode.getBoundingClientRect() : null;
|
||||
|
||||
const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY);
|
||||
if (inside) enterSafeZone();
|
||||
else leaveSafeZone();
|
||||
if (inside) {
|
||||
if (wasInside) {
|
||||
onEnterSafeZone();
|
||||
return;
|
||||
}
|
||||
enterSafeZone();
|
||||
return;
|
||||
}
|
||||
leaveSafeZone();
|
||||
}
|
||||
|
||||
function handlePointerOut(event: PointerEvent) {
|
||||
|
||||
@@ -465,6 +465,7 @@ function DraftAgentScreenContent({
|
||||
},
|
||||
enabled:
|
||||
isCreateWorktree &&
|
||||
isBranchOpen &&
|
||||
isGitDirectory &&
|
||||
!isNonGitDirectory &&
|
||||
Boolean(trimmedWorkingDir) &&
|
||||
|
||||
@@ -586,10 +586,15 @@ describe("NewWorkspaceScreen picker payload", () => {
|
||||
expect(pickerRow.style.transform).toBe("translateY(-216px)");
|
||||
});
|
||||
|
||||
it("searches only GitHub PRs for the picker", async () => {
|
||||
it("lazily searches only GitHub PRs when the picker opens", async () => {
|
||||
renderScreen();
|
||||
await flush();
|
||||
|
||||
expect(mockClient.searchGitHub).not.toHaveBeenCalled();
|
||||
|
||||
click(await findByTestId("new-workspace-ref-picker-trigger"));
|
||||
await flush();
|
||||
|
||||
expect(mockClient.searchGitHub).toHaveBeenCalledWith({
|
||||
cwd: "/repo",
|
||||
query: "",
|
||||
|
||||
@@ -183,7 +183,7 @@ export function NewWorkspaceScreen({
|
||||
limit: 20,
|
||||
});
|
||||
},
|
||||
enabled: isConnected && !!client,
|
||||
enabled: pickerOpen && isConnected && !!client,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
@@ -198,7 +198,7 @@ export function NewWorkspaceScreen({
|
||||
kinds: ["github-pr"],
|
||||
});
|
||||
},
|
||||
enabled: isConnected && !!client,
|
||||
enabled: pickerOpen && isConnected && !!client,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -55,4 +55,15 @@ describe("assistant image metadata", () => {
|
||||
),
|
||||
).toBeGreaterThan(220);
|
||||
});
|
||||
|
||||
it("estimates image-only data-image markdown without caching the full payload as text", () => {
|
||||
const source = `data:image/png;base64,${"a".repeat(512)}`;
|
||||
setAssistantImageMetadata({ source }, { width: 1200, height: 800 });
|
||||
|
||||
const imageOnlyHeight = estimateAssistantMessageHeightFromCache(``);
|
||||
const mixedHeight = estimateAssistantMessageHeightFromCache(`Text\n\n`);
|
||||
|
||||
expect(imageOnlyHeight).toBeGreaterThan(220);
|
||||
expect(mixedHeight).toBeGreaterThan(imageOnlyHeight ?? 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { resolveAssistantImageSource } from "@/utils/assistant-image-source";
|
||||
import { createImageSourceCacheKey } from "@/attachments/utils";
|
||||
|
||||
export interface AssistantImageMetadata {
|
||||
width: number;
|
||||
@@ -48,8 +49,25 @@ function normalizeAssistantImageSourceToken(value: string): string | null {
|
||||
return source || null;
|
||||
}
|
||||
|
||||
function parseAssistantImageMarkdown(markdown: string): {
|
||||
sources: string[];
|
||||
hasNonImageText: boolean;
|
||||
} {
|
||||
const sources: string[] = [];
|
||||
for (const match of markdown.matchAll(MARKDOWN_IMAGE_PATTERN)) {
|
||||
const normalized = normalizeAssistantImageSourceToken(match[1] ?? "");
|
||||
if (normalized) {
|
||||
sources.push(normalized);
|
||||
}
|
||||
}
|
||||
return {
|
||||
sources,
|
||||
hasNonImageText: markdown.replace(MARKDOWN_IMAGE_PATTERN, "").trim().length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createSourceAliasKey(source: string): string {
|
||||
return `source:${source}`;
|
||||
return `source:${createImageSourceCacheKey(source)}`;
|
||||
}
|
||||
|
||||
function createResolutionKey(input: {
|
||||
@@ -65,7 +83,7 @@ function createResolutionKey(input: {
|
||||
return null;
|
||||
}
|
||||
if (resolution.kind === "direct") {
|
||||
return `direct:${resolution.uri}`;
|
||||
return `direct:${createImageSourceCacheKey(resolution.uri)}`;
|
||||
}
|
||||
return `file:${input.serverId ?? "unknown-server"}:${resolution.cwd}:${resolution.path}`;
|
||||
}
|
||||
@@ -140,7 +158,8 @@ export function setAssistantImageMetadata(
|
||||
}
|
||||
|
||||
export function extractAssistantImageSources(markdown: string): string[] {
|
||||
const cachedParse = assistantImageParseCache.get(markdown);
|
||||
const shouldCacheParse = !/data:image\//i.test(markdown);
|
||||
const cachedParse = shouldCacheParse ? assistantImageParseCache.get(markdown) : undefined;
|
||||
if (cachedParse) {
|
||||
touchCacheEntry(
|
||||
assistantImageParseCache,
|
||||
@@ -151,32 +170,15 @@ export function extractAssistantImageSources(markdown: string): string[] {
|
||||
return cachedParse.sources;
|
||||
}
|
||||
|
||||
const sources: string[] = [];
|
||||
for (const match of markdown.matchAll(MARKDOWN_IMAGE_PATTERN)) {
|
||||
const normalized = normalizeAssistantImageSourceToken(match[1] ?? "");
|
||||
if (normalized) {
|
||||
sources.push(normalized);
|
||||
}
|
||||
const parsed = parseAssistantImageMarkdown(markdown);
|
||||
if (shouldCacheParse) {
|
||||
touchCacheEntry(assistantImageParseCache, markdown, parsed, ASSISTANT_IMAGE_PARSE_CACHE_LIMIT);
|
||||
}
|
||||
const hasNonImageText = markdown.replace(MARKDOWN_IMAGE_PATTERN, "").trim().length > 0;
|
||||
touchCacheEntry(
|
||||
assistantImageParseCache,
|
||||
markdown,
|
||||
{ sources, hasNonImageText },
|
||||
ASSISTANT_IMAGE_PARSE_CACHE_LIMIT,
|
||||
);
|
||||
return sources;
|
||||
return parsed.sources;
|
||||
}
|
||||
|
||||
export function estimateAssistantMessageHeightFromCache(markdown: string): number | null {
|
||||
const cachedParse = assistantImageParseCache.get(markdown);
|
||||
const parsed =
|
||||
cachedParse ??
|
||||
(() => {
|
||||
const sources = extractAssistantImageSources(markdown);
|
||||
const nextParsed = assistantImageParseCache.get(markdown);
|
||||
return nextParsed ?? { sources, hasNonImageText: true };
|
||||
})();
|
||||
const parsed = assistantImageParseCache.get(markdown) ?? parseAssistantImageMarkdown(markdown);
|
||||
if (parsed.sources.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -28,8 +28,10 @@ function createTestStore(): AttachmentStore {
|
||||
byteSize = input.source.blob.size;
|
||||
} else if (input.source.kind === "data_url") {
|
||||
byteSize = input.source.dataUrl.length;
|
||||
} else {
|
||||
} else if (input.source.kind === "file_uri") {
|
||||
byteSize = input.source.uri.length;
|
||||
} else {
|
||||
byteSize = input.source.base64.length;
|
||||
}
|
||||
return {
|
||||
id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"type": "module",
|
||||
"files": [
|
||||
@@ -24,8 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/relay": "0.1.60-beta.2",
|
||||
"@getpaseo/server": "0.1.60-beta.2",
|
||||
"@getpaseo/relay": "0.1.60",
|
||||
"@getpaseo/server": "0.1.60",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"main": "dist/main.js",
|
||||
@@ -13,8 +13,8 @@
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "0.1.60-beta.2",
|
||||
"@getpaseo/server": "0.1.60-beta.2",
|
||||
"@getpaseo/cli": "0.1.60",
|
||||
"@getpaseo/server": "0.1.60",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"ws": "^8.14.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"main": "build/index.js",
|
||||
"types": "build/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"description": "Paseo backend server",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -60,8 +60,8 @@
|
||||
"@ai-sdk/openai": "2.0.52",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.11",
|
||||
"@deepgram/sdk": "^3.4.0",
|
||||
"@getpaseo/highlight": "0.1.60-beta.2",
|
||||
"@getpaseo/relay": "0.1.60-beta.2",
|
||||
"@getpaseo/highlight": "0.1.60",
|
||||
"@getpaseo/relay": "0.1.60",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@mariozechner/pi-coding-agent": "^0.67.68",
|
||||
"@modelcontextprotocol/sdk": "^1.20.1",
|
||||
|
||||
@@ -652,12 +652,19 @@ describe("create_agent MCP tool", () => {
|
||||
const workspaceGitService = {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
};
|
||||
const archiveWorkspaceRecord = vi.fn(async () => undefined);
|
||||
const emitWorkspaceUpdatesForCwds = vi.fn(async () => undefined);
|
||||
const emitSessionMessage = vi.fn();
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
paseoHome,
|
||||
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts: [] }),
|
||||
workspaceGitService: workspaceGitService as any,
|
||||
archiveWorkspaceRecord,
|
||||
emitWorkspaceUpdatesForCwds,
|
||||
emitSessionMessage,
|
||||
github: createGitHubServiceStub(),
|
||||
logger,
|
||||
});
|
||||
const createTool = (server as any)._registeredTools["create_worktree"];
|
||||
@@ -678,6 +685,10 @@ describe("create_agent MCP tool", () => {
|
||||
force: true,
|
||||
reason: "archive-worktree",
|
||||
});
|
||||
expect(archiveWorkspaceRecord).toHaveBeenCalledWith(created.structuredContent.worktreePath);
|
||||
expect(Array.from(emitWorkspaceUpdatesForCwds.mock.calls[0]?.[0] ?? [])).toEqual([
|
||||
created.structuredContent.worktreePath,
|
||||
]);
|
||||
} finally {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -26,7 +26,12 @@ import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
} from "./timeline-append.js";
|
||||
import { deletePaseoWorktree, type WorktreeConfig } from "../../utils/worktree.js";
|
||||
import { getPaseoWorktreesRoot, type WorktreeConfig } from "../../utils/worktree.js";
|
||||
import {
|
||||
archivePaseoWorktree,
|
||||
killTerminalsUnderPath,
|
||||
type ArchivePaseoWorktreeDependencies,
|
||||
} from "../paseo-worktree-archive-service.js";
|
||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
|
||||
@@ -61,6 +66,7 @@ import type {
|
||||
CreatePaseoWorktreeResult,
|
||||
} from "../paseo-worktree-service.js";
|
||||
import { toWorktreeRequestError } from "../worktree-errors.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface AgentMcpServerOptions {
|
||||
agentManager: AgentManager;
|
||||
@@ -71,6 +77,9 @@ export interface AgentMcpServerOptions {
|
||||
providerRegistry?: Record<AgentProvider, ProviderDefinition> | null;
|
||||
github?: GitHubService;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"];
|
||||
emitWorkspaceUpdatesForCwds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForCwds"];
|
||||
emitSessionMessage?: ArchivePaseoWorktreeDependencies["emit"];
|
||||
createPaseoWorktree?: CreatePaseoWorktreeFn;
|
||||
paseoHome?: string;
|
||||
/**
|
||||
@@ -1733,24 +1742,58 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
async ({ cwd, worktreePath, worktreeSlug }) => {
|
||||
const repoRoot = resolveScopedCwd(cwd, { required: true });
|
||||
await deletePaseoWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreePath,
|
||||
worktreeSlug,
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
options.github?.invalidate({ cwd: repoRoot });
|
||||
try {
|
||||
await options.workspaceGitService?.getSnapshot(repoRoot, {
|
||||
force: true,
|
||||
reason: "archive-worktree",
|
||||
});
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, cwd: repoRoot },
|
||||
"Failed to force-refresh workspace git snapshot after archiving worktree",
|
||||
);
|
||||
if (!worktreePath && !worktreeSlug) {
|
||||
throw new Error("worktreePath or worktreeSlug is required");
|
||||
}
|
||||
if (!options.github) {
|
||||
throw new Error("GitHub service is required to archive worktrees");
|
||||
}
|
||||
if (!options.workspaceGitService) {
|
||||
throw new Error("WorkspaceGitService is required to archive worktrees");
|
||||
}
|
||||
if (!options.archiveWorkspaceRecord) {
|
||||
throw new Error("Workspace registry archiver is required to archive worktrees");
|
||||
}
|
||||
if (!options.emitWorkspaceUpdatesForCwds) {
|
||||
throw new Error("Workspace update emitter is required to archive worktrees");
|
||||
}
|
||||
if (!options.emitSessionMessage) {
|
||||
throw new Error("Session message emitter is required to archive worktrees");
|
||||
}
|
||||
|
||||
const targetPath =
|
||||
worktreePath ??
|
||||
join(await getPaseoWorktreesRoot(repoRoot, options.paseoHome), worktreeSlug!);
|
||||
|
||||
await archivePaseoWorktree(
|
||||
{
|
||||
paseoHome: options.paseoHome,
|
||||
github: options.github,
|
||||
workspaceGitService: options.workspaceGitService,
|
||||
agentManager,
|
||||
agentStorage,
|
||||
archiveWorkspaceRecord: options.archiveWorkspaceRecord,
|
||||
emit: options.emitSessionMessage,
|
||||
emitWorkspaceUpdatesForCwds: options.emitWorkspaceUpdatesForCwds,
|
||||
isPathWithinRoot: isSameOrDescendantPath,
|
||||
killTerminalsUnderPath: (rootPath) =>
|
||||
killTerminalsUnderPath(
|
||||
{
|
||||
terminalManager: terminalManager ?? null,
|
||||
isPathWithinRoot: isSameOrDescendantPath,
|
||||
killTrackedTerminal: () => {},
|
||||
sessionLogger: childLogger,
|
||||
},
|
||||
rootPath,
|
||||
),
|
||||
sessionLogger: childLogger,
|
||||
},
|
||||
{
|
||||
targetPath,
|
||||
repoRoot,
|
||||
requestId: "mcp:archive_worktree",
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
content: [],
|
||||
|
||||
@@ -112,6 +112,8 @@ import { LoopService } from "./loop-service.js";
|
||||
import { ScheduleService } from "./schedule/service.js";
|
||||
import { DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import { WorkspaceGitServiceImpl } from "./workspace-git-service.js";
|
||||
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
||||
import { wrapSessionMessage, type SessionOutboundMessage } from "./messages.js";
|
||||
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import { createConnectionOfferV2, encodeOfferToFragmentUrl } from "./connection-offer.js";
|
||||
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
|
||||
@@ -493,6 +495,34 @@ export async function createPaseoDaemon(
|
||||
if (mcpEnabled) {
|
||||
const agentMcpRoute = "/mcp/agents";
|
||||
const agentMcpTransports: AgentMcpTransportMap = new Map();
|
||||
const archiveWorkspaceRecordForMcp = async (workspaceId: string) => {
|
||||
const sessions = wsServer?.listActiveSessions() ?? [];
|
||||
if (sessions.length > 0) {
|
||||
await Promise.all(
|
||||
sessions.map((session) =>
|
||||
session.archiveWorkspaceRecordForExternalMutation(workspaceId),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await archivePersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
workspaceRegistry,
|
||||
projectRegistry,
|
||||
});
|
||||
};
|
||||
const emitWorkspaceUpdatesForMcpArchive = async (cwds: Iterable<string>) => {
|
||||
const cwdList = Array.from(cwds);
|
||||
await Promise.all(
|
||||
(wsServer?.listActiveSessions() ?? []).map((session) =>
|
||||
session.emitWorkspaceUpdatesForExternalCwds(cwdList),
|
||||
),
|
||||
);
|
||||
};
|
||||
const emitMcpArchiveSessionMessage = (message: SessionOutboundMessage) => {
|
||||
wsServer?.broadcast(wrapSessionMessage(message));
|
||||
};
|
||||
|
||||
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
||||
const agentMcpServer = await createAgentMcpServer({
|
||||
@@ -505,6 +535,9 @@ export async function createPaseoDaemon(
|
||||
providerRegistry,
|
||||
github,
|
||||
workspaceGitService,
|
||||
archiveWorkspaceRecord: archiveWorkspaceRecordForMcp,
|
||||
emitWorkspaceUpdatesForCwds: emitWorkspaceUpdatesForMcpArchive,
|
||||
emitSessionMessage: emitMcpArchiveSessionMessage,
|
||||
createPaseoWorktree: async (input, serviceOptions) => {
|
||||
const coreDeps = createWorktreeCoreDeps(github);
|
||||
const result = await createPaseoWorktree(input, {
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
|
||||
import { CodexAppServerAgentClient } from "../agent/providers/codex-app-server-agent.js";
|
||||
import type { AgentTimelineItem } from "../agent/agent-sdk-types.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createMessageCollector } from "../test-utils/message-collector.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import type { SessionOutboundMessage } from "../messages.js";
|
||||
import { getFullAccessConfig, isProviderAvailable } from "./agent-configs.js";
|
||||
|
||||
function tmpCwd(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "daemon-real-codex-tool-interrupt-"));
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function generateClientMessageId(): string {
|
||||
return `msg_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
function getAgentStatuses(messages: SessionOutboundMessage[], agentId: string): string[] {
|
||||
return messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.type === "agent_update" &&
|
||||
message.payload.kind === "upsert" &&
|
||||
message.payload.agent.id === agentId,
|
||||
)
|
||||
.map((message) => message.payload.agent.status);
|
||||
}
|
||||
|
||||
function getAgentStatusesBeforeFirstAssistant(
|
||||
messages: SessionOutboundMessage[],
|
||||
agentId: string,
|
||||
): string[] {
|
||||
const firstAssistantIndex = messages.findIndex(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "assistant_message",
|
||||
);
|
||||
const observedPrefix =
|
||||
firstAssistantIndex < 0 ? messages : messages.slice(0, firstAssistantIndex);
|
||||
return getAgentStatuses(observedPrefix, agentId);
|
||||
}
|
||||
|
||||
function getAssistantTexts(messages: SessionOutboundMessage[], agentId: string): string[] {
|
||||
return messages
|
||||
.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agentId &&
|
||||
message.payload.event.type === "timeline" &&
|
||||
message.payload.event.item.type === "assistant_message",
|
||||
)
|
||||
.map((message) => message.payload.event.item.text);
|
||||
}
|
||||
|
||||
function hasProviderLimitText(text: string): boolean {
|
||||
return /hit your limit|rate limit|quota|credits/i.test(text);
|
||||
}
|
||||
|
||||
function assertNoProviderLimit(timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>) {
|
||||
const assistantTexts = timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.slice(-5)
|
||||
.map((entry) => entry.item.text);
|
||||
const limitText = assistantTexts.find((text) => hasProviderLimitText(text));
|
||||
if (limitText) {
|
||||
throw new Error(`Codex provider rejected the run: ${limitText}`);
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeTimelineItems(timeline: Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>) {
|
||||
return timeline.entries.slice(-15).map((entry) => {
|
||||
const item = entry.item;
|
||||
if (item.type === "assistant_message") {
|
||||
return { type: item.type, text: item.text };
|
||||
}
|
||||
if (item.type === "tool_call") {
|
||||
return {
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
status: item.status,
|
||||
callId: item.callId,
|
||||
};
|
||||
}
|
||||
if (item.type === "user_message") {
|
||||
return { type: item.type, text: item.text };
|
||||
}
|
||||
return { type: item.type };
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForAssistantWaitingOnSleep(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
timeoutMs = 75_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 });
|
||||
assertNoProviderLimit(timeline);
|
||||
const assistantText = timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.map((entry) => entry.item.text)
|
||||
.join("\n");
|
||||
if (/still running|waiting/i.test(assistantText) && !/`done`|\bdone\b/i.test(assistantText)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agentId, { limit: 100 }).catch(() => null);
|
||||
const recentToolCalls =
|
||||
timeline?.entries
|
||||
.filter((entry) => entry.item.type === "tool_call")
|
||||
.slice(-10)
|
||||
.map((entry) => ({
|
||||
name: entry.item.name,
|
||||
status: entry.item.status,
|
||||
callId: entry.item.callId,
|
||||
})) ?? [];
|
||||
const recentAssistantTexts =
|
||||
timeline?.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.slice(-5)
|
||||
.map((entry) => entry.item.text) ?? [];
|
||||
throw new Error(
|
||||
`Timed out waiting for Codex to report it was waiting on sleep. Recent tool_calls=${JSON.stringify(recentToolCalls)} recent assistant text=${JSON.stringify(recentAssistantTexts)}`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("daemon E2E (real codex) - send message during tool call", () => {
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = await isProviderAvailable("codex");
|
||||
});
|
||||
|
||||
beforeEach((context) => {
|
||||
if (!canRun) {
|
||||
context.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not emit an idle agent_update between UI send and the replacement Codex turn", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let collector: ReturnType<typeof createMessageCollector> | null = null;
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "primary" } });
|
||||
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "codex-tool-interrupt-repro",
|
||||
...getFullAccessConfig("codex"),
|
||||
});
|
||||
|
||||
collector = createMessageCollector(client);
|
||||
|
||||
await client.sendMessage(
|
||||
agent.id,
|
||||
"Run `sleep 60 && echo done` and tell me what it outputs. Be brief.",
|
||||
);
|
||||
await client.waitForAgentUpsert(
|
||||
agent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
60_000,
|
||||
);
|
||||
await waitForAssistantWaitingOnSleep(client, agent.id);
|
||||
|
||||
collector.clear();
|
||||
|
||||
await client.sendAgentMessage(agent.id, "Reply with exactly: INTERRUPT_RECEIVED", {
|
||||
messageId: generateClientMessageId(),
|
||||
});
|
||||
|
||||
const finish = await client.waitForFinish(agent.id, 120_000);
|
||||
const postSendMessages = [...collector.messages];
|
||||
const postSendStatuses = getAgentStatuses(postSendMessages, agent.id);
|
||||
const statusesBeforeFirstAssistant = getAgentStatusesBeforeFirstAssistant(
|
||||
postSendMessages,
|
||||
agent.id,
|
||||
);
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
|
||||
|
||||
if (finish.status !== "idle") {
|
||||
const snapshot = await client.fetchAgent(agent.id);
|
||||
throw new Error(
|
||||
`Expected idle after replacement, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(getAssistantTexts(postSendMessages, agent.id))} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(statusesBeforeFirstAssistant).not.toContain("idle");
|
||||
expect(statusesBeforeFirstAssistant).not.toContain("error");
|
||||
|
||||
const assistantTexts = timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.map((entry) => {
|
||||
const item = entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>;
|
||||
return item.text;
|
||||
});
|
||||
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
|
||||
expect(assistantTexts.some((text) => text.toUpperCase().includes("INTERRUPT_RECEIVED"))).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
collector?.unsubscribe();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
test("does not emit an idle agent_update when a second prompt is sent 200ms after the first", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { codex: new CodexAppServerAgentClient(logger) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let collector: ReturnType<typeof createMessageCollector> | null = null;
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: { subscriptionId: "primary" } });
|
||||
|
||||
const agent = await client.createAgent({
|
||||
cwd,
|
||||
title: "codex-quick-follow-up-repro",
|
||||
...getFullAccessConfig("codex"),
|
||||
});
|
||||
|
||||
collector = createMessageCollector(client);
|
||||
|
||||
await client.sendAgentMessage(
|
||||
agent.id,
|
||||
"Run `sleep 60 && echo done` and tell me what it outputs. Be brief.",
|
||||
{
|
||||
messageId: generateClientMessageId(),
|
||||
},
|
||||
);
|
||||
await sleep(200);
|
||||
|
||||
collector.clear();
|
||||
|
||||
await client.sendAgentMessage(agent.id, "Reply with exactly: QUICK_FOLLOW_UP_RECEIVED", {
|
||||
messageId: generateClientMessageId(),
|
||||
});
|
||||
|
||||
const finish = await client.waitForFinish(agent.id, 120_000);
|
||||
const postSendMessages = [...collector.messages];
|
||||
const postSendStatuses = getAgentStatuses(postSendMessages, agent.id);
|
||||
const statusesBeforeFirstAssistant = getAgentStatusesBeforeFirstAssistant(
|
||||
postSendMessages,
|
||||
agent.id,
|
||||
);
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 });
|
||||
|
||||
if (finish.status !== "idle") {
|
||||
const snapshot = await client.fetchAgent(agent.id);
|
||||
throw new Error(
|
||||
`Expected idle after quick follow-up, got ${finish.status}. postSendStatuses=${JSON.stringify(postSendStatuses)} statusesBeforeFirstAssistant=${JSON.stringify(statusesBeforeFirstAssistant)} postSendAssistantTexts=${JSON.stringify(getAssistantTexts(postSendMessages, agent.id))} agentStatus=${snapshot?.agent.status ?? null} recentTimeline=${JSON.stringify(summarizeTimelineItems(timeline))}`,
|
||||
);
|
||||
}
|
||||
|
||||
expect(statusesBeforeFirstAssistant).not.toContain("idle");
|
||||
expect(statusesBeforeFirstAssistant).not.toContain("error");
|
||||
|
||||
const assistantTexts = timeline.entries
|
||||
.filter((entry) => entry.item.type === "assistant_message")
|
||||
.map((entry) => {
|
||||
const item = entry.item as Extract<AgentTimelineItem, { type: "assistant_message" }>;
|
||||
return item.text;
|
||||
});
|
||||
expect(assistantTexts.some((text) => text.includes("[System Error]"))).toBe(false);
|
||||
expect(
|
||||
assistantTexts.some((text) => text.toUpperCase().includes("QUICK_FOLLOW_UP_RECEIVED")),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
collector?.unsubscribe();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 300_000);
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import type { SessionOutboundMessage } from "./messages.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
||||
import type { GitHubService } from "../services/github-service.js";
|
||||
import { deletePaseoWorktree, resolvePaseoWorktreeRootForCwd } from "../utils/worktree.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
|
||||
type EmitSessionMessage = (message: SessionOutboundMessage) => void;
|
||||
|
||||
export type ArchivePaseoWorktreeDependencies = {
|
||||
paseoHome?: string;
|
||||
github: GitHubService;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
|
||||
agentStorage: Pick<AgentStorage, "list" | "remove">;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
emit: EmitSessionMessage;
|
||||
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
|
||||
sessionLogger?: Logger;
|
||||
};
|
||||
|
||||
export type KillTerminalsUnderPathDependencies = {
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void;
|
||||
detachTerminalStream?: (terminalId: string, options: { emitExit: boolean }) => void;
|
||||
sessionLogger: Logger;
|
||||
terminalManager: TerminalManager | null;
|
||||
};
|
||||
|
||||
export async function archivePaseoWorktree(
|
||||
dependencies: ArchivePaseoWorktreeDependencies,
|
||||
options: {
|
||||
targetPath: string;
|
||||
repoRoot: string | null;
|
||||
worktreesRoot?: string;
|
||||
requestId: string;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
let targetPath = options.targetPath;
|
||||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
if (resolvedWorktree) {
|
||||
targetPath = resolvedWorktree.worktreePath;
|
||||
}
|
||||
|
||||
const removedAgents = new Set<string>();
|
||||
const affectedWorkspaceCwds = new Set<string>([targetPath]);
|
||||
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
|
||||
|
||||
const liveAgents = dependencies.agentManager
|
||||
.listAgents()
|
||||
.filter((agent) => dependencies.isPathWithinRoot(targetPath, agent.cwd));
|
||||
for (const agent of liveAgents) {
|
||||
removedAgents.add(agent.id);
|
||||
affectedWorkspaceCwds.add(agent.cwd);
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
|
||||
}
|
||||
|
||||
let storedRecords: StoredAgentRecord[] = [];
|
||||
try {
|
||||
storedRecords = await dependencies.agentStorage.list();
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, targetPath },
|
||||
"Failed to list stored agents during worktree archive; continuing",
|
||||
);
|
||||
}
|
||||
const matchingStoredRecords = storedRecords.filter((record) =>
|
||||
dependencies.isPathWithinRoot(targetPath, record.cwd),
|
||||
);
|
||||
for (const record of matchingStoredRecords) {
|
||||
removedAgents.add(record.id);
|
||||
affectedWorkspaceCwds.add(record.cwd);
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
|
||||
}
|
||||
|
||||
const agentIdsToRemoveFromStorage = new Set<string>([
|
||||
...liveAgents.map((agent) => agent.id),
|
||||
...matchingStoredRecords.map((record) => record.id),
|
||||
]);
|
||||
|
||||
const teardownResults = await Promise.allSettled([
|
||||
...liveAgents.map((agent) => dependencies.agentManager.closeAgent(agent.id)),
|
||||
dependencies.killTerminalsUnderPath(targetPath),
|
||||
]);
|
||||
|
||||
for (const result of teardownResults) {
|
||||
if (result.status === "rejected") {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: result.reason, targetPath },
|
||||
"Worktree teardown step failed during archive; continuing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const agentIdsToRemove = Array.from(agentIdsToRemoveFromStorage);
|
||||
const storageRemovalResults = await Promise.allSettled(
|
||||
agentIdsToRemove.map((agentId) => dependencies.agentStorage.remove(agentId)),
|
||||
);
|
||||
storageRemovalResults.forEach((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
return;
|
||||
}
|
||||
dependencies.sessionLogger?.warn(
|
||||
{
|
||||
err: result.reason,
|
||||
agentId: agentIdsToRemove[index],
|
||||
targetPath,
|
||||
},
|
||||
"Failed to remove archived worktree agent from storage; continuing",
|
||||
);
|
||||
});
|
||||
|
||||
await deletePaseoWorktree({
|
||||
cwd: options.repoRoot,
|
||||
worktreePath: targetPath,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
|
||||
if (options.repoRoot) {
|
||||
try {
|
||||
await dependencies.workspaceGitService.getSnapshot(options.repoRoot, {
|
||||
force: true,
|
||||
reason: "archive-worktree",
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, cwd: options.repoRoot },
|
||||
"Failed to force-refresh workspace git snapshot after archiving worktree",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cwd of affectedWorkspaceCwds) {
|
||||
dependencies.github.invalidate({ cwd });
|
||||
}
|
||||
|
||||
for (const workspaceId of affectedWorkspaceIds) {
|
||||
try {
|
||||
await dependencies.archiveWorkspaceRecord(workspaceId);
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, workspaceId },
|
||||
"Failed to archive workspace record; worktree FS already removed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentId of removedAgents) {
|
||||
dependencies.emit({
|
||||
type: "agent_deleted",
|
||||
payload: {
|
||||
agentId,
|
||||
requestId: options.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds);
|
||||
|
||||
return Array.from(removedAgents);
|
||||
}
|
||||
|
||||
export async function killTerminalsUnderPath(
|
||||
dependencies: KillTerminalsUnderPathDependencies,
|
||||
rootPath: string,
|
||||
): Promise<void> {
|
||||
const terminalManager = dependencies.terminalManager;
|
||||
if (!terminalManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalIds: string[] = [];
|
||||
const terminalDirectories = [...terminalManager.listDirectories()];
|
||||
for (const terminalCwd of terminalDirectories) {
|
||||
if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const terminals = await terminalManager.getTerminals(terminalCwd);
|
||||
for (const terminal of terminals) {
|
||||
terminalIds.push(terminal.id);
|
||||
}
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger.warn(
|
||||
{ err: error, cwd: terminalCwd },
|
||||
"Failed to enumerate worktree terminals during archive",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (terminalIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
terminalIds.map(async (terminalId) => {
|
||||
try {
|
||||
dependencies.detachTerminalStream?.(terminalId, { emitExit: true });
|
||||
await terminalManager.killTerminalAndWait(terminalId, {
|
||||
gracefulTimeoutMs: 2000,
|
||||
forceTimeoutMs: 1500,
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger.warn(
|
||||
{ err: error, terminalId },
|
||||
"Terminal kill escalation failed during archive; proceeding anyway",
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -165,6 +165,7 @@ import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { PushTokenStore } from "./push/token-store.js";
|
||||
import { type WorktreeConfig } from "../utils/worktree.js";
|
||||
import { runAsyncWorktreeBootstrap } from "./worktree-bootstrap.js";
|
||||
import { archivePersistedWorkspaceRecord } from "./workspace-archive-service.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
import type { ScriptRouteStore } from "./script-proxy.js";
|
||||
import {
|
||||
@@ -211,8 +212,8 @@ import {
|
||||
handlePaseoWorktreeArchiveRequest as handleWorktreeArchiveRequest,
|
||||
handlePaseoWorktreeListRequest as handleWorktreeListRequest,
|
||||
handleWorkspaceSetupStatusRequest as handleWorkspaceSetupStatusRequestMessage,
|
||||
killTerminalsUnderPath as killWorktreeTerminalsUnderPath,
|
||||
} from "./worktree-session.js";
|
||||
import { killTerminalsUnderPath as killWorktreeTerminalsUnderPath } from "./paseo-worktree-archive-service.js";
|
||||
import { toWorktreeWireError } from "./worktree-errors.js";
|
||||
|
||||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS);
|
||||
@@ -907,6 +908,14 @@ export class Session {
|
||||
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId], { skipReconcile: true });
|
||||
}
|
||||
|
||||
async archiveWorkspaceRecordForExternalMutation(workspaceId: string): Promise<void> {
|
||||
await this.archiveWorkspaceRecord(workspaceId);
|
||||
}
|
||||
|
||||
async emitWorkspaceUpdatesForExternalCwds(cwds: Iterable<string>): Promise<void> {
|
||||
await this.emitWorkspaceUpdatesForCwds(cwds);
|
||||
}
|
||||
|
||||
async warmWorkspaceGitDataForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
|
||||
await this.primeWorkspaceGitWatchFingerprintForWorkspace(workspace);
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
|
||||
@@ -6403,24 +6412,20 @@ export class Session {
|
||||
}
|
||||
|
||||
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||
const existingWorkspace = await this.workspaceRegistry.get(workspaceId);
|
||||
if (!existingWorkspace || existingWorkspace.archivedAt) {
|
||||
const existingWorkspace = await archivePersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
archivedAt,
|
||||
workspaceRegistry: this.workspaceRegistry,
|
||||
projectRegistry: this.projectRegistry,
|
||||
});
|
||||
if (!existingWorkspace) {
|
||||
this.removeWorkspaceGitSubscription(workspaceId);
|
||||
return;
|
||||
}
|
||||
|
||||
const nextArchivedAt = archivedAt ?? new Date().toISOString();
|
||||
await this.workspaceRegistry.archive(workspaceId, nextArchivedAt);
|
||||
await this.removeWorkspaceGitWatchTarget(existingWorkspace.cwd);
|
||||
this.scriptRuntimeStore?.removeForWorkspace(existingWorkspace.cwd);
|
||||
this.removeWorkspaceGitSubscription(workspaceId);
|
||||
|
||||
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
|
||||
);
|
||||
if (siblingWorkspaces.length === 0) {
|
||||
await this.projectRegistry.archive(existingWorkspace.projectId, nextArchivedAt);
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcileAndEmitWorkspaceUpdates(): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type {
|
||||
PersistedWorkspaceRecord,
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
} from "./workspace-registry.js";
|
||||
|
||||
export async function archivePersistedWorkspaceRecord(input: {
|
||||
workspaceId: string;
|
||||
workspaceRegistry: Pick<WorkspaceRegistry, "get" | "list" | "archive">;
|
||||
projectRegistry: Pick<ProjectRegistry, "archive">;
|
||||
archivedAt?: string;
|
||||
}): Promise<PersistedWorkspaceRecord | null> {
|
||||
const existingWorkspace = await input.workspaceRegistry.get(input.workspaceId);
|
||||
if (!existingWorkspace) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existingWorkspace.archivedAt) {
|
||||
return existingWorkspace;
|
||||
}
|
||||
|
||||
const archivedAt = input.archivedAt ?? new Date().toISOString();
|
||||
await input.workspaceRegistry.archive(input.workspaceId, archivedAt);
|
||||
const activeSiblings = (await input.workspaceRegistry.list()).filter(
|
||||
(workspace) => workspace.projectId === existingWorkspace.projectId && !workspace.archivedAt,
|
||||
);
|
||||
if (activeSiblings.length === 0) {
|
||||
await input.projectRegistry.archive(existingWorkspace.projectId, archivedAt);
|
||||
}
|
||||
|
||||
return existingWorkspace;
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import pino, { type Logger } from "pino";
|
||||
|
||||
import type { SessionOutboundMessage, WorkspaceDescriptorPayload } from "./messages.js";
|
||||
import { ScriptRouteStore } from "./script-proxy.js";
|
||||
import { archivePaseoWorktree } from "./paseo-worktree-archive-service.js";
|
||||
import {
|
||||
archivePaseoWorktree,
|
||||
buildAgentSessionConfig,
|
||||
handlePaseoWorktreeArchiveRequest,
|
||||
handlePaseoWorktreeListRequest,
|
||||
|
||||
@@ -2,8 +2,6 @@ import type { Logger } from "pino";
|
||||
import { basename } from "node:path";
|
||||
|
||||
import type { AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent/agent-storage.js";
|
||||
import {
|
||||
type AgentAttachment,
|
||||
type GitSetupOptions,
|
||||
@@ -14,7 +12,6 @@ import {
|
||||
} from "./messages.js";
|
||||
import type { PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import { normalizeWorkspaceId as normalizePersistedWorkspaceId } from "./workspace-registry-model.js";
|
||||
import {
|
||||
applyWorktreeSetupProgressEvent,
|
||||
buildWorktreeSetupDetail,
|
||||
@@ -28,10 +25,8 @@ import type { GitHubService } from "../services/github-service.js";
|
||||
import type { CheckoutExistingBranchResult } from "../utils/checkout-git.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import {
|
||||
deletePaseoWorktree,
|
||||
getWorktreeSetupCommands,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
resolvePaseoWorktreeRootForCwd,
|
||||
resolveWorktreeRuntimeEnv,
|
||||
runWorktreeSetupCommands,
|
||||
slugify,
|
||||
@@ -45,6 +40,10 @@ import type {
|
||||
CreatePaseoWorktreeInput,
|
||||
CreatePaseoWorktreeResult,
|
||||
} from "./paseo-worktree-service.js";
|
||||
import {
|
||||
archivePaseoWorktree,
|
||||
type ArchivePaseoWorktreeDependencies,
|
||||
} from "./paseo-worktree-archive-service.js";
|
||||
import { toWorktreeWireError } from "./worktree-errors.js";
|
||||
|
||||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/;
|
||||
@@ -82,20 +81,6 @@ type BuildAgentSessionConfigDependencies = {
|
||||
github?: Pick<GitHubService, "invalidate">;
|
||||
};
|
||||
|
||||
type ArchivePaseoWorktreeDependencies = {
|
||||
paseoHome?: string;
|
||||
github: GitHubService;
|
||||
workspaceGitService: WorkspaceGitService;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "closeAgent">;
|
||||
agentStorage: Pick<AgentStorage, "list" | "remove">;
|
||||
archiveWorkspaceRecord: (workspaceId: string) => Promise<void>;
|
||||
emit: EmitSessionMessage;
|
||||
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTerminalsUnderPath: (rootPath: string) => Promise<void>;
|
||||
sessionLogger?: Logger;
|
||||
};
|
||||
|
||||
type CreatePaseoWorktreeInBackgroundDependencies = {
|
||||
paseoHome?: string;
|
||||
emitWorkspaceUpdateForCwd: (cwd: string, options?: { dedupeGitState?: boolean }) => Promise<void>;
|
||||
@@ -136,14 +121,6 @@ type HandleCreatePaseoWorktreeRequestDependencies = {
|
||||
}) => Promise<void>;
|
||||
};
|
||||
|
||||
type KillTerminalsUnderPathDependencies = {
|
||||
isPathWithinRoot: (rootPath: string, candidatePath: string) => boolean;
|
||||
killTrackedTerminal: (terminalId: string, options?: { emitExit: boolean }) => void;
|
||||
detachTerminalStream?: (terminalId: string, options: { emitExit: boolean }) => void;
|
||||
sessionLogger: Logger;
|
||||
terminalManager: TerminalManager | null;
|
||||
};
|
||||
|
||||
export async function buildAgentSessionConfig(
|
||||
dependencies: BuildAgentSessionConfigDependencies,
|
||||
config: AgentSessionConfig,
|
||||
@@ -368,151 +345,13 @@ export async function handlePaseoWorktreeListRequest(
|
||||
}
|
||||
}
|
||||
|
||||
export async function archivePaseoWorktree(
|
||||
dependencies: ArchivePaseoWorktreeDependencies,
|
||||
options: {
|
||||
targetPath: string;
|
||||
repoRoot: string | null;
|
||||
worktreesRoot?: string;
|
||||
requestId: string;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
let targetPath = options.targetPath;
|
||||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
if (resolvedWorktree) {
|
||||
targetPath = resolvedWorktree.worktreePath;
|
||||
}
|
||||
|
||||
const removedAgents = new Set<string>();
|
||||
const affectedWorkspaceCwds = new Set<string>([targetPath]);
|
||||
const affectedWorkspaceIds = new Set<string>([normalizePersistedWorkspaceId(targetPath)]);
|
||||
|
||||
const liveAgents = dependencies.agentManager
|
||||
.listAgents()
|
||||
.filter((agent) => dependencies.isPathWithinRoot(targetPath, agent.cwd));
|
||||
for (const agent of liveAgents) {
|
||||
removedAgents.add(agent.id);
|
||||
affectedWorkspaceCwds.add(agent.cwd);
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(agent.cwd));
|
||||
}
|
||||
|
||||
let storedRecords: StoredAgentRecord[] = [];
|
||||
try {
|
||||
storedRecords = await dependencies.agentStorage.list();
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, targetPath },
|
||||
"Failed to list stored agents during worktree archive; continuing",
|
||||
);
|
||||
}
|
||||
const matchingStoredRecords = storedRecords.filter((record) =>
|
||||
dependencies.isPathWithinRoot(targetPath, record.cwd),
|
||||
);
|
||||
for (const record of matchingStoredRecords) {
|
||||
removedAgents.add(record.id);
|
||||
affectedWorkspaceCwds.add(record.cwd);
|
||||
affectedWorkspaceIds.add(normalizePersistedWorkspaceId(record.cwd));
|
||||
}
|
||||
|
||||
const agentIdsToRemoveFromStorage = new Set<string>([
|
||||
...liveAgents.map((agent) => agent.id),
|
||||
...matchingStoredRecords.map((record) => record.id),
|
||||
]);
|
||||
|
||||
// Fan out agent close + terminal teardown concurrently. We never let a
|
||||
// per-item failure abort the archive — the FS delete must still run so the
|
||||
// worktree doesn't get stuck half-dead.
|
||||
const teardownResults = await Promise.allSettled([
|
||||
...liveAgents.map((agent) => dependencies.agentManager.closeAgent(agent.id)),
|
||||
dependencies.killTerminalsUnderPath(targetPath),
|
||||
]);
|
||||
|
||||
for (const result of teardownResults) {
|
||||
if (result.status === "rejected") {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: result.reason, targetPath },
|
||||
"Worktree teardown step failed during archive; continuing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Agent storage removal runs after closeAgent so file handles on the agent
|
||||
// state file are released; still allSettled so a single bad record can't
|
||||
// derail the rest.
|
||||
const agentIdsToRemove = Array.from(agentIdsToRemoveFromStorage);
|
||||
const storageRemovalResults = await Promise.allSettled(
|
||||
agentIdsToRemove.map((agentId) => dependencies.agentStorage.remove(agentId)),
|
||||
);
|
||||
storageRemovalResults.forEach((result, index) => {
|
||||
if (result.status === "fulfilled") {
|
||||
return;
|
||||
}
|
||||
dependencies.sessionLogger?.warn(
|
||||
{
|
||||
err: result.reason,
|
||||
agentId: agentIdsToRemove[index],
|
||||
targetPath,
|
||||
},
|
||||
"Failed to remove archived worktree agent from storage; continuing",
|
||||
);
|
||||
});
|
||||
|
||||
await deletePaseoWorktree({
|
||||
cwd: options.repoRoot,
|
||||
worktreePath: targetPath,
|
||||
worktreesRoot: options.worktreesRoot,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
});
|
||||
|
||||
if (options.repoRoot) {
|
||||
try {
|
||||
await dependencies.workspaceGitService.getSnapshot(options.repoRoot, {
|
||||
force: true,
|
||||
reason: "archive-worktree",
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, cwd: options.repoRoot },
|
||||
"Failed to force-refresh workspace git snapshot after archiving worktree",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const cwd of affectedWorkspaceCwds) {
|
||||
dependencies.github.invalidate({ cwd });
|
||||
}
|
||||
|
||||
for (const workspaceId of affectedWorkspaceIds) {
|
||||
try {
|
||||
await dependencies.archiveWorkspaceRecord(workspaceId);
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger?.warn(
|
||||
{ err: error, workspaceId },
|
||||
"Failed to archive workspace record; worktree FS already removed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentId of removedAgents) {
|
||||
dependencies.emit({
|
||||
type: "agent_deleted",
|
||||
payload: {
|
||||
agentId,
|
||||
requestId: options.requestId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await dependencies.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds);
|
||||
|
||||
return Array.from(removedAgents);
|
||||
}
|
||||
|
||||
export async function handlePaseoWorktreeArchiveRequest(
|
||||
dependencies: Omit<ArchivePaseoWorktreeDependencies, "emitWorkspaceUpdatesForCwds"> & {
|
||||
dependencies: Omit<
|
||||
ArchivePaseoWorktreeDependencies,
|
||||
"emitWorkspaceUpdatesForCwds" | "workspaceGitService"
|
||||
> & {
|
||||
emit: EmitSessionMessage;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
emitWorkspaceUpdatesForCwds: (cwds: Iterable<string>) => Promise<void>;
|
||||
},
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
|
||||
@@ -533,6 +372,9 @@ export async function handlePaseoWorktreeArchiveRequest(
|
||||
}
|
||||
targetPath = match.path;
|
||||
}
|
||||
if (!targetPath) {
|
||||
throw new Error("worktreePath could not be resolved");
|
||||
}
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
@@ -779,53 +621,3 @@ export async function runWorktreeSetupInBackground(
|
||||
await dependencies.emitWorkspaceUpdateForCwd(worktree.worktreePath);
|
||||
}
|
||||
}
|
||||
|
||||
export async function killTerminalsUnderPath(
|
||||
dependencies: KillTerminalsUnderPathDependencies,
|
||||
rootPath: string,
|
||||
): Promise<void> {
|
||||
const terminalManager = dependencies.terminalManager;
|
||||
if (!terminalManager) {
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalIds: string[] = [];
|
||||
const terminalDirectories = [...terminalManager.listDirectories()];
|
||||
for (const terminalCwd of terminalDirectories) {
|
||||
if (!dependencies.isPathWithinRoot(rootPath, terminalCwd)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const terminals = await terminalManager.getTerminals(terminalCwd);
|
||||
for (const terminal of terminals) {
|
||||
terminalIds.push(terminal.id);
|
||||
}
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger.warn(
|
||||
{ err: error, cwd: terminalCwd },
|
||||
"Failed to enumerate worktree terminals during archive",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (terminalIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
terminalIds.map(async (terminalId) => {
|
||||
try {
|
||||
dependencies.detachTerminalStream?.(terminalId, { emitExit: true });
|
||||
await terminalManager.killTerminalAndWait(terminalId, {
|
||||
gracefulTimeoutMs: 2000,
|
||||
forceTimeoutMs: 1500,
|
||||
});
|
||||
} catch (error) {
|
||||
dependencies.sessionLogger.warn(
|
||||
{ err: error, terminalId },
|
||||
"Terminal kill escalation failed during archive; proceeding anyway",
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,69 @@ describe("shared messages stream parsing", () => {
|
||||
expect(parsed.payload.entries[0]?.item.type).toBe("assistant_message");
|
||||
});
|
||||
|
||||
it("parses legacy worktree setup timeline entries without per-command log", () => {
|
||||
const parsed = FetchAgentTimelineResponseMessageSchema.parse({
|
||||
type: "fetch_agent_timeline_response",
|
||||
payload: {
|
||||
requestId: "req-legacy-setup",
|
||||
agentId: "agent_legacy_setup",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "canonical",
|
||||
epoch: "epoch-setup",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { epoch: "epoch-setup", seq: 1 },
|
||||
endCursor: { epoch: "epoch-setup", seq: 1 },
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
provider: "codex",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "setup-1",
|
||||
name: "paseo_worktree_setup",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "worktree_setup",
|
||||
worktreePath: "/repo/.paseo/worktrees/feature",
|
||||
branchName: "feature",
|
||||
log: "setup complete",
|
||||
commands: [
|
||||
{
|
||||
index: 1,
|
||||
command: "npm install",
|
||||
cwd: "/repo/.paseo/worktrees/feature",
|
||||
status: "completed",
|
||||
exitCode: 0,
|
||||
durationMs: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
timestamp: "2026-04-22T00:00:00.000Z",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
},
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
const item = parsed.payload.entries[0]?.item;
|
||||
expect(item?.type).toBe("tool_call");
|
||||
if (item?.type !== "tool_call" || item.detail.type !== "worktree_setup") {
|
||||
throw new Error("Expected worktree setup tool call");
|
||||
}
|
||||
expect(item.detail.commands[0]?.log).toBe("");
|
||||
});
|
||||
|
||||
it("parses explicit shutdown and restart lifecycle request payloads as distinct message types", () => {
|
||||
const shutdownParsed = SessionInboundMessageSchema.safeParse({
|
||||
type: "shutdown_server_request",
|
||||
|
||||
@@ -251,7 +251,11 @@ export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
|
||||
}),
|
||||
]);
|
||||
|
||||
export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionRequest> = z.object({
|
||||
export const AgentPermissionRequestPayloadSchema: z.ZodType<
|
||||
AgentPermissionRequest,
|
||||
z.ZodTypeDef,
|
||||
unknown
|
||||
> = z.object({
|
||||
id: z.string(),
|
||||
provider: AgentProviderSchema,
|
||||
name: z.string(),
|
||||
@@ -286,7 +290,7 @@ const WorktreeSetupCommandSnapshotSchema = z.object({
|
||||
index: z.number().int().positive(),
|
||||
command: z.string(),
|
||||
cwd: z.string(),
|
||||
log: z.string(),
|
||||
log: z.string().optional().default(""),
|
||||
status: z.enum(["running", "completed", "failed"]),
|
||||
exitCode: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative().optional(),
|
||||
@@ -301,95 +305,96 @@ const WorktreeSetupDetailPayloadSchema = z.object({
|
||||
truncated: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail> = z.discriminatedUnion("type", [
|
||||
WorktreeSetupDetailPayloadSchema,
|
||||
z.object({
|
||||
type: z.literal("shell"),
|
||||
command: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().nullable().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("read"),
|
||||
filePath: z.string(),
|
||||
content: z.string().optional(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("edit"),
|
||||
filePath: z.string(),
|
||||
oldString: z.string().optional(),
|
||||
newString: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("write"),
|
||||
filePath: z.string(),
|
||||
content: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("search"),
|
||||
query: z.string(),
|
||||
toolName: z.enum(["search", "grep", "glob", "web_search"]).optional(),
|
||||
content: z.string().optional(),
|
||||
filePaths: z.array(z.string()).optional(),
|
||||
webResults: z
|
||||
.array(
|
||||
const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, z.ZodTypeDef, unknown> =
|
||||
z.discriminatedUnion("type", [
|
||||
WorktreeSetupDetailPayloadSchema,
|
||||
z.object({
|
||||
type: z.literal("shell"),
|
||||
command: z.string(),
|
||||
cwd: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
exitCode: z.number().nullable().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("read"),
|
||||
filePath: z.string(),
|
||||
content: z.string().optional(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("edit"),
|
||||
filePath: z.string(),
|
||||
oldString: z.string().optional(),
|
||||
newString: z.string().optional(),
|
||||
unifiedDiff: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("write"),
|
||||
filePath: z.string(),
|
||||
content: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("search"),
|
||||
query: z.string(),
|
||||
toolName: z.enum(["search", "grep", "glob", "web_search"]).optional(),
|
||||
content: z.string().optional(),
|
||||
filePaths: z.array(z.string()).optional(),
|
||||
webResults: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
annotations: z.array(z.string()).optional(),
|
||||
numFiles: z.number().optional(),
|
||||
numMatches: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
durationSeconds: z.number().optional(),
|
||||
truncated: z.boolean().optional(),
|
||||
mode: z.enum(["content", "files_with_matches", "count"]).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("fetch"),
|
||||
url: z.string(),
|
||||
prompt: z.string().optional(),
|
||||
result: z.string().optional(),
|
||||
code: z.number().optional(),
|
||||
codeText: z.string().optional(),
|
||||
bytes: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("sub_agent"),
|
||||
subAgentType: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
log: z.string(),
|
||||
actions: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
url: z.string(),
|
||||
index: z.number().int().positive(),
|
||||
toolName: z.string(),
|
||||
summary: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
annotations: z.array(z.string()).optional(),
|
||||
numFiles: z.number().optional(),
|
||||
numMatches: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
durationSeconds: z.number().optional(),
|
||||
truncated: z.boolean().optional(),
|
||||
mode: z.enum(["content", "files_with_matches", "count"]).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("fetch"),
|
||||
url: z.string(),
|
||||
prompt: z.string().optional(),
|
||||
result: z.string().optional(),
|
||||
code: z.number().optional(),
|
||||
codeText: z.string().optional(),
|
||||
bytes: z.number().optional(),
|
||||
durationMs: z.number().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("sub_agent"),
|
||||
subAgentType: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
log: z.string(),
|
||||
actions: z.array(
|
||||
z.object({
|
||||
index: z.number().int().positive(),
|
||||
toolName: z.string(),
|
||||
summary: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("plain_text"),
|
||||
label: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
icon: z.enum(TOOL_CALL_ICON_NAMES).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("plan"),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("unknown"),
|
||||
input: UnknownValueSchema,
|
||||
output: UnknownValueSchema,
|
||||
}),
|
||||
]);
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("plain_text"),
|
||||
label: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
icon: z.enum(TOOL_CALL_ICON_NAMES).optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("plan"),
|
||||
text: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("unknown"),
|
||||
input: UnknownValueSchema,
|
||||
output: UnknownValueSchema,
|
||||
}),
|
||||
]);
|
||||
|
||||
const ToolCallBasePayloadSchema = z
|
||||
.object({
|
||||
@@ -2060,29 +2065,34 @@ const WorkspaceGitHubRuntimePayloadSchema = z
|
||||
.optional()
|
||||
.nullable();
|
||||
|
||||
export const WorkspaceDescriptorPayloadSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
projectDisplayName: z.string(),
|
||||
projectRootPath: z.string(),
|
||||
workspaceDirectory: z.string(),
|
||||
projectKind: z.enum(["git", "non_git", "directory"]),
|
||||
// COMPAT(workspaces): keep legacy directory workspace kind parseable.
|
||||
workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]),
|
||||
name: z.string(),
|
||||
status: WorkspaceStateBucketSchema,
|
||||
activityAt: z.string().nullable(),
|
||||
diffStat: z
|
||||
.object({
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
scripts: z.array(WorkspaceScriptPayloadSchema).default([]),
|
||||
gitRuntime: WorkspaceGitRuntimePayloadSchema,
|
||||
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
|
||||
});
|
||||
export const WorkspaceDescriptorPayloadSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
projectDisplayName: z.string(),
|
||||
projectRootPath: z.string(),
|
||||
workspaceDirectory: z.string().optional(),
|
||||
projectKind: z.enum(["git", "non_git", "directory"]),
|
||||
// COMPAT(workspaces): keep legacy directory workspace kind parseable.
|
||||
workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]),
|
||||
name: z.string(),
|
||||
status: WorkspaceStateBucketSchema,
|
||||
activityAt: z.string().nullable(),
|
||||
diffStat: z
|
||||
.object({
|
||||
additions: z.number(),
|
||||
deletions: z.number(),
|
||||
})
|
||||
.nullable()
|
||||
.optional(),
|
||||
scripts: z.array(WorkspaceScriptPayloadSchema).default([]),
|
||||
gitRuntime: WorkspaceGitRuntimePayloadSchema,
|
||||
githubRuntime: WorkspaceGitHubRuntimePayloadSchema,
|
||||
})
|
||||
.transform((workspace) => ({
|
||||
...workspace,
|
||||
workspaceDirectory: workspace.workspaceDirectory ?? workspace.projectRootPath,
|
||||
}));
|
||||
|
||||
export const AgentUpdateMessageSchema = z.object({
|
||||
type: z.literal("agent_update"),
|
||||
|
||||
@@ -254,6 +254,33 @@ describe("workspace message schemas", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("parses legacy workspace descriptors without workspaceDirectory", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_update",
|
||||
payload: {
|
||||
kind: "upsert",
|
||||
workspace: {
|
||||
id: "legacy-workspace",
|
||||
projectId: "legacy-project",
|
||||
projectDisplayName: "repo",
|
||||
projectRootPath: "/repo",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "repo",
|
||||
status: "done",
|
||||
activityAt: null,
|
||||
scripts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.type).toBe("workspace_update");
|
||||
if (parsed.type !== "workspace_update" || parsed.payload.kind !== "upsert") {
|
||||
throw new Error("Expected workspace_update upsert payload");
|
||||
}
|
||||
expect(parsed.payload.workspace.workspaceDirectory).toBe("/repo");
|
||||
});
|
||||
|
||||
test("parses legacy workspace descriptor enum values", () => {
|
||||
const parsed = SessionOutboundMessageSchema.parse({
|
||||
type: "workspace_update",
|
||||
|
||||
@@ -194,6 +194,24 @@ describe("checkout git utilities", () => {
|
||||
expect(branch).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the branch being rebased when HEAD is detached during a rebase", async () => {
|
||||
execSync("git checkout -b feature/rebase-test", { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "file.txt"), "feature\n");
|
||||
execSync("git add file.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'feature change'", { cwd: repoDir });
|
||||
|
||||
execSync("git checkout main", { cwd: repoDir });
|
||||
writeFileSync(join(repoDir, "file.txt"), "main\n");
|
||||
execSync("git add file.txt", { cwd: repoDir });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'main change'", { cwd: repoDir });
|
||||
|
||||
execSync("git checkout feature/rebase-test", { cwd: repoDir });
|
||||
expect(() => execSync("git rebase main", { cwd: repoDir, stdio: "pipe" })).toThrow();
|
||||
|
||||
const branch = await getCurrentBranch(repoDir);
|
||||
expect(branch).toBe("feature/rebase-test");
|
||||
});
|
||||
|
||||
it("handles status/diff/commit in a normal repo", async () => {
|
||||
writeFileSync(join(repoDir, "file.txt"), "updated\n");
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { resolve, dirname, basename } from "path";
|
||||
import { existsSync, realpathSync } from "fs";
|
||||
import { open as openFile, stat as statFile } from "fs/promises";
|
||||
import { open as openFile, readFile, stat as statFile } from "fs/promises";
|
||||
import { TTLCache } from "@isaacs/ttlcache";
|
||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||
@@ -701,12 +701,34 @@ export async function getCurrentBranch(cwd: string): Promise<string | null> {
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const branch = stdout.trim();
|
||||
if (branch === "HEAD") {
|
||||
return await getRebaseHeadBranch(cwd);
|
||||
}
|
||||
return branch.length > 0 ? branch : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRebaseHeadBranch(cwd: string): Promise<string | null> {
|
||||
for (const path of ["rebase-merge/head-name", "rebase-apply/head-name"]) {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--git-path", path], {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
});
|
||||
const headName = (await readFile(resolve(cwd, stdout.trim()), "utf8")).trim();
|
||||
if (headName.startsWith("refs/heads/")) {
|
||||
return headName.slice("refs/heads/".length) || null;
|
||||
}
|
||||
return headName || null;
|
||||
} catch {
|
||||
// Try the other rebase backend.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getWorktreeRoot(cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await runGitCommand(["rev-parse", "--show-toplevel"], {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.60-beta.2",
|
||||
"version": "0.1.60",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user