mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf22464a5 | ||
|
|
705cf93681 | ||
|
|
0e60432de9 | ||
|
|
0e5bcbd9f5 | ||
|
|
a7d9bb34fc | ||
|
|
32a420db1b | ||
|
|
f56f277606 | ||
|
|
6b325bdbcd | ||
|
|
7ba608fcb2 |
@@ -1,4 +1,5 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -67,3 +68,54 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
|
||||
await page.getByRole("link", { name: linkName, exact: true }).click();
|
||||
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
|
||||
content,
|
||||
);
|
||||
}
|
||||
|
||||
async function returnToNestedLinks(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
|
||||
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
testInfo.setTimeout(120_000);
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "nested-file-link-",
|
||||
title: "Nested file links",
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse:
|
||||
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
|
||||
mockStreamingAssistantIntervalMs: 5,
|
||||
},
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
|
||||
await writeFile(
|
||||
path.join(agent.cwd, "repo-a/schema/x.md"),
|
||||
"# Nested file content\n\nDetails\n",
|
||||
);
|
||||
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
|
||||
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
|
||||
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
|
||||
await openAgentRoute(page, agent);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
|
||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
|
||||
await readLinkedGuide(page, "nested guide", "Nested file content");
|
||||
await returnToNestedLinks(page);
|
||||
await readLinkedGuide(page, "exact guide", "Exact file content");
|
||||
await returnToNestedLinks(page);
|
||||
await page.getByRole("link", { name: "missing guide", exact: true }).click();
|
||||
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,6 +81,8 @@ for (const folder of [
|
||||
"docs/guides",
|
||||
"docs/guides/",
|
||||
"docs/指南",
|
||||
"examples/next.js/",
|
||||
"examples/next.js",
|
||||
]) {
|
||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
@@ -92,6 +94,9 @@ for (const folder of [
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
|
||||
await mkdir(path.dirname(competingFile), { recursive: true });
|
||||
await writeFile(competingFile, "This is a file, not the requested folder.\n");
|
||||
await session.client.waitForFinish(session.agentId, 30_000);
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
|
||||
@@ -468,6 +468,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
() =>
|
||||
createInlinePathOpener({
|
||||
workspaceRoot,
|
||||
resolveRelativeFiles: true,
|
||||
client,
|
||||
openFile: openInlineFile,
|
||||
openDirectory: openInlineDirectory,
|
||||
|
||||
@@ -64,7 +64,10 @@ it("ignores an older probe after a newer file link opens", async () => {
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
client: {
|
||||
listDirectory: () => pendingProbe,
|
||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
||||
},
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
@@ -91,7 +94,10 @@ it("ignores a pending probe when its view is left", async () => {
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
client: { listDirectory: () => pendingProbe },
|
||||
client: {
|
||||
listDirectory: () => pendingProbe,
|
||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
||||
},
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
@@ -130,3 +136,45 @@ it("sends folders outside the workspace away from the workspace Files panel", as
|
||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||
expect(workspaceFolders).toEqual(["docs"]);
|
||||
});
|
||||
|
||||
it("keeps a later selection when an earlier nested-file lookup finishes last", async () => {
|
||||
const { createInlinePathOpener } = await import("./path-open");
|
||||
const lookup = Promise.withResolvers<{
|
||||
entries: Array<{ path: string; kind: "file" }>;
|
||||
error: null;
|
||||
}>();
|
||||
const queries: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const opener = createInlinePathOpener({
|
||||
workspaceRoot: "/repo",
|
||||
resolveRelativeFiles: true,
|
||||
client: {
|
||||
listDirectory: async () => {
|
||||
throw new Error("Not a directory");
|
||||
},
|
||||
getDirectorySuggestions: async ({ query }: { query: string }) => {
|
||||
queries.push(query);
|
||||
return lookup.promise;
|
||||
},
|
||||
},
|
||||
openFile: (location) => {
|
||||
opened.push(location.path);
|
||||
},
|
||||
openDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
openOutsideDirectory: (directory) => {
|
||||
opened.push(directory);
|
||||
},
|
||||
});
|
||||
const earlier = opener.open(
|
||||
{ raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 },
|
||||
"side",
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(queries).toEqual(["schema/x.md"]);
|
||||
await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side");
|
||||
lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null });
|
||||
await earlier;
|
||||
expect(opened).toEqual(["guide.md"]);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type OpenFileDisposition,
|
||||
} from "@/workspace/file-open";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver";
|
||||
|
||||
interface PathOpenInput {
|
||||
file?: string;
|
||||
@@ -50,7 +51,11 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathO
|
||||
|
||||
export function createInlinePathOpener(input: {
|
||||
workspaceRoot: string;
|
||||
client: PathOpenInput["client"];
|
||||
client: {
|
||||
listDirectory(root: string, path: string): Promise<unknown>;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
} | null;
|
||||
resolveRelativeFiles?: boolean;
|
||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||
openDirectory: (path: string) => void;
|
||||
openOutsideDirectory: (path: string) => void;
|
||||
@@ -73,10 +78,21 @@ export function createInlinePathOpener(input: {
|
||||
} else if (destination.kind === "outsideDirectory") {
|
||||
input.openOutsideDirectory(destination.directoryPath);
|
||||
} else if (normalized.file) {
|
||||
const resolvedTarget =
|
||||
input.resolveRelativeFiles && input.client
|
||||
? await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client),
|
||||
})
|
||||
: target;
|
||||
if (ownGeneration !== generation) return;
|
||||
const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot);
|
||||
if (!resolved?.file) return;
|
||||
const location = normalizeWorkspaceFileLocation({
|
||||
path: normalized.file,
|
||||
lineStart: target.lineStart,
|
||||
lineEnd: target.lineEnd,
|
||||
path: resolved.file,
|
||||
lineStart: resolvedTarget.lineStart,
|
||||
lineEnd: resolvedTarget.lineEnd,
|
||||
});
|
||||
if (location) input.openFile(location, disposition);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
getAssistantFileLinkToken,
|
||||
resolveMissingNestedRelativeFile,
|
||||
UnresolvedFileLinkError,
|
||||
type AssistantFileLinkContext,
|
||||
type DirectorySuggestionEntry,
|
||||
@@ -424,6 +425,174 @@ describe("fetchDaemonResolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMissingNestedRelativeFile", () => {
|
||||
const target = {
|
||||
raw: "schema/x.md:12-14",
|
||||
path: "/Users/test/project/schema/x.md",
|
||||
lineStart: 12,
|
||||
lineEnd: 14,
|
||||
};
|
||||
|
||||
it("opens a nested-repo suffix when the workspace-root path is missing", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([
|
||||
{
|
||||
query: "schema/x.md",
|
||||
cwd: "/Users/test/project",
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
...target,
|
||||
path: "/Users/test/project/repo-a/schema/x.md",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the exact workspace path when suggestions return that relative path", async () => {
|
||||
const { getDirectorySuggestions } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(result).toEqual(target);
|
||||
});
|
||||
|
||||
it("keeps the original target when suggestions are empty", async () => {
|
||||
const { getDirectorySuggestions } = suggestionsFromMap({});
|
||||
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(target);
|
||||
});
|
||||
|
||||
it("keeps the original target when the daemon throws", async () => {
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions: unavailableSuggestions,
|
||||
}),
|
||||
).resolves.toEqual(target);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a bare filename", { ...target, path: "/Users/test/project/file.ts" }],
|
||||
["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }],
|
||||
["a home-relative path", { ...target, path: "~/schema/x.md" }],
|
||||
])("does not search %s", async (_label, candidate) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target: candidate,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([]);
|
||||
expect(result).toEqual(candidate);
|
||||
});
|
||||
|
||||
it("does not search when the workspace root is blank", async () => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target,
|
||||
workspaceRoot: " ",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([]);
|
||||
expect(result).toEqual(target);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/Users/test/project/schema/x.md", "/Users/test/project"],
|
||||
["file:///Users/test/project/schema/x.md", "/Users/test/project"],
|
||||
["C:/repo/schema/x.md", "C:/repo"],
|
||||
["\\\\server\\share\\schema\\x.md", "//server/share"],
|
||||
])("keeps absolute destination %s", async (raw, workspaceRoot) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` };
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target: absoluteTarget,
|
||||
workspaceRoot,
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(absoluteTarget);
|
||||
expect(searches).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["docs/guides", "./docs/guides", "docs/guides/"])(
|
||||
"keeps folder href %s direct",
|
||||
async (raw) => {
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }],
|
||||
});
|
||||
const folderTarget = { raw, path: "/Users/test/project/docs/guides" };
|
||||
await expect(
|
||||
resolveMissingNestedRelativeFile({
|
||||
target: folderTarget,
|
||||
workspaceRoot: "/Users/test/project",
|
||||
getDirectorySuggestions,
|
||||
}),
|
||||
).resolves.toEqual(folderTarget);
|
||||
expect(searches).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("normalizes a Windows workspace root before the suffix lookup", async () => {
|
||||
const windowsTarget = {
|
||||
raw: "schema/x.md",
|
||||
path: "C:\\repo\\schema\\x.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
};
|
||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
||||
});
|
||||
|
||||
const result = await resolveMissingNestedRelativeFile({
|
||||
target: windowsTarget,
|
||||
workspaceRoot: "C:\\repo\\",
|
||||
getDirectorySuggestions,
|
||||
});
|
||||
|
||||
expect(searches).toEqual([
|
||||
{
|
||||
query: "schema/x.md",
|
||||
cwd: "C:\\repo\\",
|
||||
matchMode: "suffix",
|
||||
limit: 1,
|
||||
},
|
||||
]);
|
||||
expect(result.path).toBe("C:/repo/repo-a/schema/x.md");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantFileLinkToken", () => {
|
||||
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
||||
expect(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type InlinePathTarget,
|
||||
} from "./parse";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { isAbsolutePath } from "@/utils/path";
|
||||
|
||||
export interface AssistantFileLinkSource {
|
||||
href: string;
|
||||
@@ -105,6 +106,62 @@ export async function fetchDaemonResolution({
|
||||
};
|
||||
}
|
||||
|
||||
/** Only file-looking paths nested inside the workspace need suffix search. */
|
||||
function nestedRelativeLookupQuery(
|
||||
targetPath: string,
|
||||
workspaceRoot: string | undefined,
|
||||
): string | null {
|
||||
const root = workspaceRoot?.trim();
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
|
||||
const normalizedPath = targetPath.replace(/\\/g, "/");
|
||||
const prefix = `${normalizedRoot}/`;
|
||||
if (!normalizedPath.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
const relative = normalizedPath.slice(prefix.length);
|
||||
if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) {
|
||||
return null;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
/** When a markdown link points at workspaceRoot/schema/x.md and that file is
|
||||
* missing, ask the same suffix lookup bare names already use. An exact hit
|
||||
* stays on that path. A suffix hit such as repo-a/schema/x.md replaces it.
|
||||
* Lookup failure keeps the original target.
|
||||
*/
|
||||
export async function resolveMissingNestedRelativeFile(input: {
|
||||
target: InlinePathTarget;
|
||||
workspaceRoot?: string;
|
||||
getDirectorySuggestions: GetDirectorySuggestions;
|
||||
}): Promise<InlinePathTarget> {
|
||||
if (
|
||||
isAbsoluteInlineCodeToken(input.target.raw) ||
|
||||
input.target.raw.startsWith("~/") ||
|
||||
input.target.raw.endsWith("/")
|
||||
) {
|
||||
return input.target;
|
||||
}
|
||||
const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot);
|
||||
if (!query) {
|
||||
return input.target;
|
||||
}
|
||||
try {
|
||||
return await fetchDaemonResolution({
|
||||
ambiguousQuery: query,
|
||||
token: input.target.raw,
|
||||
target: input.target,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
getDirectorySuggestions: input.getDirectorySuggestions,
|
||||
});
|
||||
} catch {
|
||||
return input.target;
|
||||
}
|
||||
}
|
||||
|
||||
export function classifyForResolution(
|
||||
source: AssistantFileLinkSource,
|
||||
context: AssistantFileLinkContext,
|
||||
@@ -215,11 +272,7 @@ export function shouldResolveDirectFileThroughSuggestions(input: {
|
||||
}
|
||||
|
||||
function isAbsoluteInlineCodeToken(token: string): boolean {
|
||||
return (
|
||||
token.startsWith("/") ||
|
||||
token.toLowerCase().startsWith("file://") ||
|
||||
/^[A-Za-z]:[\\/]/.test(token)
|
||||
);
|
||||
return isAbsolutePath(token) || token.toLowerCase().startsWith("file://");
|
||||
}
|
||||
|
||||
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||
@@ -126,17 +126,13 @@ afterEach(async () => {
|
||||
});
|
||||
});
|
||||
|
||||
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
|
||||
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
|
||||
|
||||
beforeAll(async () => {
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
({ useAgentInputDraft } = await import("./input-draft"));
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
const { useAgentInputDraft } = await import("./input-draft");
|
||||
|
||||
describe("useAgentInputDraft live contract", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchAgentHistoryEntry,
|
||||
@@ -13,18 +13,8 @@ import { allAgentHistoryQueryKey } from "./agent-history-query-key";
|
||||
}
|
||||
).__DEV__ = false;
|
||||
|
||||
type UseAgentHistoryModule = typeof import("./use-agent-history");
|
||||
|
||||
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
|
||||
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
|
||||
let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("./use-agent-history");
|
||||
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
|
||||
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
|
||||
collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors;
|
||||
});
|
||||
const { fetchAgentHistoryBatch, fetchAgentHistoryPage, collectAgentHistoryHostErrors } =
|
||||
await import("./use-agent-history");
|
||||
|
||||
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
|
||||
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
|
||||
|
||||
@@ -3505,6 +3505,10 @@ export class AgentManager {
|
||||
"agent.rewind.complete",
|
||||
);
|
||||
} catch (error) {
|
||||
// Native rewind can commit a fork before history refresh fails. Recovery
|
||||
// must resume that latest handle rather than the abandoned conversation.
|
||||
this.refreshSessionPersistence(agent);
|
||||
await this.persistSnapshot(agent);
|
||||
this.logger.warn(
|
||||
{ err: error, agentId, provider: agent.provider, messageId, mode },
|
||||
"agent.rewind.failed",
|
||||
|
||||
@@ -53,6 +53,7 @@ const CAPABILITIES = [
|
||||
interface ProviderHarnessOptions {
|
||||
capabilities?: ProviderConnection["capabilities"];
|
||||
completeTurn?: boolean;
|
||||
closeAcknowledgementOnly?: boolean;
|
||||
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
|
||||
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
|
||||
}
|
||||
@@ -208,6 +209,7 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
}
|
||||
if (input.type === "session.close") {
|
||||
emit({ type: "session.closed", sessionId: input.sessionId });
|
||||
if (options.closeAcknowledgementOnly) return;
|
||||
}
|
||||
if ("requestId" in input) {
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
@@ -246,6 +248,572 @@ function eventsOfType(events: AgentStreamEvent[], type: AgentStreamEvent["type"]
|
||||
return events.filter((event) => event.type === type);
|
||||
}
|
||||
|
||||
function createRewindProviderHarness(
|
||||
options: {
|
||||
reuseAnswerId?: boolean;
|
||||
forkOnRevert?: boolean;
|
||||
handleInput?: ProviderHarnessOptions["handleInput"];
|
||||
} = {},
|
||||
) {
|
||||
const nativeMessages: string[] = [];
|
||||
let originalMessages: string[] | null = null;
|
||||
const harness = createProviderHarness({
|
||||
closeAcknowledgementOnly: true,
|
||||
capabilities: [
|
||||
...CAPABILITIES,
|
||||
"session.revert.conversation",
|
||||
"session.revert.files",
|
||||
"session.revert.both",
|
||||
],
|
||||
async handleInput(input, emit) {
|
||||
if (await options.handleInput?.(input, emit)) return true;
|
||||
if (input.type === "session.open" && input.history === "replay") {
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: [
|
||||
...CAPABILITIES,
|
||||
"session.revert.conversation",
|
||||
"session.revert.files",
|
||||
"session.revert.both",
|
||||
],
|
||||
restoration: "core",
|
||||
persistence: input.persistence ?? { version: 1, data: { token: "root" } },
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
const messages =
|
||||
options.forkOnRevert &&
|
||||
(input.persistence?.data as { token?: string })?.token === "root" &&
|
||||
originalMessages
|
||||
? originalMessages
|
||||
: nativeMessages;
|
||||
for (const [index, text] of messages.entries()) {
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: { type: "user_message", id: `user-${text}`, text, revertToken: text },
|
||||
});
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
id: options.reuseAnswerId ? `answer-${index + 1}` : `answer-${text}`,
|
||||
text: `Reply ${text}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.prompt") {
|
||||
if (input.prompt.input.type !== "message") throw new Error("Expected a message");
|
||||
const text = input.prompt.input.content
|
||||
.flatMap((part) => (part.type === "text" ? [part.text] : []))
|
||||
.join("");
|
||||
nativeMessages.push(text);
|
||||
const turnId = `turn-${text}`;
|
||||
emit({
|
||||
type: "session.prompt_result",
|
||||
sessionId: input.sessionId,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
result: { type: "turn", turnId },
|
||||
});
|
||||
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "started" });
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "user_message",
|
||||
id: `user-${text}`,
|
||||
text,
|
||||
clientMessageId: input.prompt.clientMessageId,
|
||||
revertToken: text,
|
||||
},
|
||||
});
|
||||
emit({
|
||||
type: "timeline.item",
|
||||
sessionId: input.sessionId,
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
id: options.reuseAnswerId ? `answer-${nativeMessages.length}` : `answer-${text}`,
|
||||
text: `Reply ${text}`,
|
||||
},
|
||||
});
|
||||
emit({ type: "session.turn", sessionId: input.sessionId, turnId, state: "completed" });
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.revert") {
|
||||
if (options.forkOnRevert && input.scope !== "files") {
|
||||
originalMessages = [...nativeMessages];
|
||||
emit({
|
||||
type: "session.persistence",
|
||||
sessionId: input.sessionId,
|
||||
persistence: { version: 1, data: { token: "fork" } },
|
||||
});
|
||||
}
|
||||
if (input.scope !== "files")
|
||||
nativeMessages.splice(nativeMessages.indexOf(String(input.token)));
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
return { ...harness, nativeMessages };
|
||||
}
|
||||
|
||||
async function withRewindAgent(
|
||||
run: (context: {
|
||||
manager: AgentManager;
|
||||
agentId: string;
|
||||
nativeMessages: string[];
|
||||
messages: () => string[];
|
||||
}) => Promise<void>,
|
||||
options: Parameters<typeof createRewindProviderHarness>[0] = {},
|
||||
) {
|
||||
const harness = createRewindProviderHarness(options);
|
||||
const logger = createTestLogger();
|
||||
const registry = new PluginAgentClientRegistry(logger);
|
||||
registry.replace([harness.registration]);
|
||||
const manager = new AgentManager({
|
||||
logger,
|
||||
clients: registry.clients(),
|
||||
providerDefinitions: registry.definitions(),
|
||||
});
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: tmpdir() },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const messages = () =>
|
||||
manager
|
||||
.getTimeline(agent.id)
|
||||
.flatMap((item) =>
|
||||
item.type === "user_message" || item.type === "assistant_message" ? [item.text] : [],
|
||||
);
|
||||
try {
|
||||
await manager.runAgent(agent.id, "A", { clientMessageId: "client-A" });
|
||||
await manager.runAgent(agent.id, "B", { clientMessageId: "client-B" });
|
||||
await manager.runAgent(agent.id, "C", { clientMessageId: "client-C" });
|
||||
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
|
||||
await run({ manager, agentId: agent.id, nativeMessages: harness.nativeMessages, messages });
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
await registry.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
test("conversation rewind discards withdrawn plugin turns before and after a replacement prompt", async () => {
|
||||
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
|
||||
await manager.rewind(agentId, "user-B", "conversation");
|
||||
const afterRewind = messages();
|
||||
expect(nativeMessages).toEqual(["A"]);
|
||||
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
|
||||
const afterReplacement = messages();
|
||||
expect(nativeMessages).toEqual(["A", "D"]);
|
||||
expect({ afterRewind, afterReplacement }).toEqual({
|
||||
afterRewind: ["A", "Reply A"],
|
||||
afterReplacement: ["A", "Reply A", "D", "Reply D"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("file-only plugin rewind preserves the conversation before and after a replacement prompt", async () => {
|
||||
await withRewindAgent(async ({ manager, agentId, nativeMessages, messages }) => {
|
||||
await manager.rewind(agentId, "user-B", "files");
|
||||
expect(nativeMessages).toEqual(["A", "B", "C"]);
|
||||
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C"]);
|
||||
await manager.runAgent(agentId, "D", { clientMessageId: "client-D" });
|
||||
expect(messages()).toEqual(["A", "Reply A", "B", "Reply B", "C", "Reply C", "D", "Reply D"]);
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin rewind restores translation state when a replacement reuses a discarded item id", async () => {
|
||||
const harness = createRewindProviderHarness({ reuseAnswerId: true });
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
|
||||
const live: AgentStreamEvent[] = [];
|
||||
try {
|
||||
await session.run("A");
|
||||
await session.run("B");
|
||||
session.subscribe((event) => live.push(event));
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
live.length = 0;
|
||||
await session.run("B revised");
|
||||
const assistant = live.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
|
||||
);
|
||||
expect(harness.nativeMessages).toEqual(["A", "B revised"]);
|
||||
expect(assistant).toEqual(["Reply B revised"]);
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
async function withPluginRewindSession(
|
||||
options: Parameters<typeof createRewindProviderHarness>[0],
|
||||
run: (
|
||||
session: Awaited<ReturnType<AgentClient["createSession"]>>,
|
||||
harness: ReturnType<typeof createRewindProviderHarness>,
|
||||
) => Promise<void>,
|
||||
) {
|
||||
const harness = createRewindProviderHarness(options);
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({ provider: harness.registration.id, cwd: tmpdir() });
|
||||
try {
|
||||
await session.run("A");
|
||||
await session.run("B");
|
||||
await session.run("C");
|
||||
await run(session, harness);
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
async function pluginMessages(session: Awaited<ReturnType<AgentClient["createSession"]>>) {
|
||||
const events = await Array.fromAsync(session.streamHistory());
|
||||
return events.flatMap((event) =>
|
||||
event.type === "timeline" &&
|
||||
(event.item.type === "user_message" || event.item.type === "assistant_message")
|
||||
? [event.item.text]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
test("combined plugin rewind replays the retained native branch", async () => {
|
||||
await withPluginRewindSession({}, async (session) => {
|
||||
await session.revertBoth!({ messageId: "user-B" });
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
|
||||
await session.revertConversation!({ messageId: "user-A" });
|
||||
expect(await pluginMessages(session)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type === "session.revert")
|
||||
emit({
|
||||
type: "session.persistence",
|
||||
sessionId: input.sessionId,
|
||||
persistence: { version: 1, data: { token: "fork" } },
|
||||
});
|
||||
return false;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
expect(harness.inputs.findLast((input) => input.type === "session.open")).toMatchObject({
|
||||
persistence: { version: 1, data: { token: "fork" } },
|
||||
history: "replay",
|
||||
});
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("failed native plugin rewind preserves history and does not reopen", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.revert") return false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "native rewind failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"native rewind failed",
|
||||
);
|
||||
expect(await pluginMessages(session)).toEqual([
|
||||
"A",
|
||||
"Reply A",
|
||||
"B",
|
||||
"Reply B",
|
||||
"C",
|
||||
"Reply C",
|
||||
]);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("failed plugin rewind replay blocks prompts until retry without repeating native rewind", async () => {
|
||||
let failReplay = true;
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
|
||||
return false;
|
||||
failReplay = false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "replay failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"replay failed",
|
||||
);
|
||||
await expect(session.run("D")).rejects.toThrow("Retry the rewind or reload");
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
await session.run("D");
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A", "D", "Reply D"]);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("retrying a failed plugin rewind replay replaces the manager timeline before prompting", async () => {
|
||||
let failReplay = true;
|
||||
await withRewindAgent(
|
||||
async ({ manager, agentId, messages }) => {
|
||||
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
|
||||
"replay failed",
|
||||
);
|
||||
await expect(manager.runAgent(agentId, "D")).rejects.toThrow("Retry the rewind or reload");
|
||||
await manager.rewind(agentId, "user-B", "conversation");
|
||||
expect(messages()).toEqual(["A", "Reply A"]);
|
||||
await manager.runAgent(agentId, "D");
|
||||
expect(messages()).toEqual(["A", "Reply A", "D", "Reply D"]);
|
||||
},
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
|
||||
return false;
|
||||
failReplay = false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "replay failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("reload after failed plugin rewind replay resumes the committed fork", async () => {
|
||||
let failReplay = true;
|
||||
await withRewindAgent(
|
||||
async ({ manager, agentId, messages }) => {
|
||||
await expect(manager.rewind(agentId, "user-B", "conversation")).rejects.toThrow(
|
||||
"replay failed",
|
||||
);
|
||||
await manager.reloadAgentSession(agentId, undefined, { rehydrateFromDisk: true });
|
||||
await manager.hydrateTimelineFromProvider(agentId, { force: true });
|
||||
expect(messages()).toEqual(["A", "Reply A"]);
|
||||
},
|
||||
{
|
||||
forkOnRevert: true,
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type === "session.close") {
|
||||
emit({ type: "request.completed", requestId: input.requestId });
|
||||
return true;
|
||||
}
|
||||
if (input.type !== "session.open" || input.history !== "replay" || !failReplay)
|
||||
return false;
|
||||
failReplay = false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "replay failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("closing during plugin rewind replay closes each native binding once", async () => {
|
||||
let opened!: () => void;
|
||||
let release!: () => void;
|
||||
const replayStarted = new Promise<void>((resolve) => {
|
||||
opened = resolve;
|
||||
});
|
||||
const replayAllowed = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input) => {
|
||||
if (input.type === "session.open" && input.history === "replay") {
|
||||
opened();
|
||||
await replayAllowed;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
const rewind = session.revertConversation!({ messageId: "user-B" });
|
||||
await replayStarted;
|
||||
const closing = session.close();
|
||||
release();
|
||||
await Promise.all([rewind, closing]);
|
||||
const closes = harness.inputs.flatMap((input) =>
|
||||
input.type === "session.close" ? [input.sessionId] : [],
|
||||
);
|
||||
expect(closes).toHaveLength(2);
|
||||
expect(new Set(closes).size).toBe(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin rewind retries a failed close before reopening the native writer", async () => {
|
||||
let failClose = true;
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.close" || !failClose) return false;
|
||||
failClose = false;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "close failed" },
|
||||
});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"close failed",
|
||||
);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.open")).toHaveLength(1);
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.revert")).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin rewind retries permission settlement before retiring its session", async () => {
|
||||
let failPermission = true;
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input) => {
|
||||
if (input.type !== "session.permission" || !failPermission) return false;
|
||||
failPermission = false;
|
||||
throw new Error("permission delivery failed");
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
const opened = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
if (opened.type !== "session.open") throw new Error("Missing root");
|
||||
harness.emit({
|
||||
type: "session.permission",
|
||||
sessionId: opened.sessionId,
|
||||
request: { id: "pending", name: "tool", kind: "tool" },
|
||||
});
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"permission delivery failed",
|
||||
);
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.close")).toHaveLength(0);
|
||||
expect(await pluginMessages(session)).toEqual(["A", "Reply A"]);
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.permission")).toHaveLength(2);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("plugin rewind replaces child listeners and settles old pending permissions", async () => {
|
||||
await withPluginRewindSession({}, async (session, harness) => {
|
||||
const root = harness.inputs.find((input) => input.type === "session.open")!;
|
||||
if (root.type !== "session.open") throw new Error("Missing root");
|
||||
harness.emit({
|
||||
type: "session.permission",
|
||||
sessionId: root.sessionId,
|
||||
request: { id: "old-permission", name: "tool", kind: "tool", title: "Run tool" },
|
||||
});
|
||||
expect(session.getPendingPermissions()).toHaveLength(1);
|
||||
await session.revertConversation!({ messageId: "user-B" });
|
||||
expect(session.getPendingPermissions()).toEqual([]);
|
||||
expect(harness.inputs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.permission",
|
||||
permissionId: "old-permission",
|
||||
response: { behavior: "deny", message: "Session replaced after rewind" },
|
||||
}),
|
||||
);
|
||||
harness.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: "child-1",
|
||||
item: { type: "assistant_message", id: "child-message", text: "Discarded child update" },
|
||||
});
|
||||
const history = await Array.fromAsync(session.streamHistory());
|
||||
expect(history.filter((event) => event.type === "provider_subagent")).toEqual([]);
|
||||
const reopened = harness.inputs.findLast((input) => input.type === "session.open")!;
|
||||
if (reopened.type !== "session.open") throw new Error("Missing reopened root");
|
||||
harness.emit({
|
||||
type: "session.opened",
|
||||
sessionId: "child-1",
|
||||
parentSessionId: reopened.sessionId,
|
||||
capabilities: [],
|
||||
restoration: "parent",
|
||||
cwd: tmpdir(),
|
||||
});
|
||||
harness.emit({
|
||||
type: "timeline.item",
|
||||
sessionId: "child-1",
|
||||
item: { type: "assistant_message", id: "child-message", text: "New child reply" },
|
||||
});
|
||||
const next = await Array.fromAsync(session.streamHistory());
|
||||
expect(next).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "provider_subagent",
|
||||
event: expect.objectContaining({
|
||||
type: "timeline",
|
||||
item: expect.objectContaining({ text: "New child reply" }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("nonpersisted plugin sessions reject conversation rewind before native mutation", async () => {
|
||||
await withPluginRewindSession(
|
||||
{
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.open") return false;
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: [...CAPABILITIES, "session.revert.conversation", "session.revert.files"],
|
||||
restoration: "core",
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (session, harness) => {
|
||||
expect(session.capabilities.supportsRewindConversation).toBe(false);
|
||||
await expect(session.revertConversation!({ messageId: "user-B" })).rejects.toThrow(
|
||||
"requires session persistence",
|
||||
);
|
||||
expect(harness.inputs.filter((input) => input.type === "session.revert")).toEqual([]);
|
||||
await session.revertFiles!({ messageId: "user-B" });
|
||||
expect(harness.nativeMessages).toEqual(["A", "B", "C"]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function openNestedChildren(rootSessionId: string, emit: (event: ProviderEvent) => void) {
|
||||
for (const [sessionId, parentSessionId] of [
|
||||
["a", rootSessionId],
|
||||
|
||||
@@ -603,6 +603,7 @@ class ProviderRuntimeSession {
|
||||
>();
|
||||
private readonly activeTurnIds = new Set<string>();
|
||||
private terminal = false;
|
||||
private closePromise: Promise<void> | null = null;
|
||||
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
|
||||
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
|
||||
persistence: ProviderPersistence | null = null;
|
||||
@@ -724,6 +725,14 @@ class ProviderRuntimeSession {
|
||||
this.runtime.removeSession(this.id, this.providerSessionId);
|
||||
return;
|
||||
}
|
||||
this.closePromise ??= this.closeNativeSession().catch((error) => {
|
||||
this.closePromise = null;
|
||||
throw error;
|
||||
});
|
||||
await this.closePromise;
|
||||
}
|
||||
|
||||
private async closeNativeSession(): Promise<void> {
|
||||
try {
|
||||
await this.runtime.complete({
|
||||
type: "session.close",
|
||||
@@ -732,9 +741,9 @@ class ProviderRuntimeSession {
|
||||
});
|
||||
} catch (error) {
|
||||
if (!this.runtime.isClosed) throw error;
|
||||
} finally {
|
||||
this.runtime.removeSession(this.id, this.providerSessionId);
|
||||
}
|
||||
// A failed close still needs its routing for a subsequent session.closed ack.
|
||||
this.runtime.removeSession(this.id, this.providerSessionId);
|
||||
}
|
||||
|
||||
beginOpen(requestId: string): Promise<void> {
|
||||
@@ -1092,11 +1101,40 @@ class PluginAgentClient implements AgentClient {
|
||||
persistence: input.persistence,
|
||||
history: input.history,
|
||||
});
|
||||
const session = new PluginAgentSession(this.provider, bridge, () => {
|
||||
this.rootsBySession.delete(bridge.id);
|
||||
});
|
||||
this.rootsBySession.set(bridge.id, session);
|
||||
this.attachPendingChildren();
|
||||
const forget = () => {
|
||||
for (const [id, owner] of this.rootsBySession) {
|
||||
if (owner === session) this.rootsBySession.delete(id);
|
||||
}
|
||||
};
|
||||
const bind = (next: ProviderRuntimeSession) => {
|
||||
this.rootsBySession.set(next.id, session);
|
||||
this.attachPendingChildren();
|
||||
};
|
||||
const config = mapSessionConfig(input.config, input.launchContext, input.persist);
|
||||
const session = new PluginAgentSession(
|
||||
this.provider,
|
||||
bridge,
|
||||
forget,
|
||||
async (persistence, state) => {
|
||||
forget();
|
||||
return await this.runtime.openSession({
|
||||
sessionId: randomUUID(),
|
||||
config: {
|
||||
...config,
|
||||
model: state.model,
|
||||
mode: state.mode,
|
||||
thinkingOption: state.thinkingOption,
|
||||
settings: Object.fromEntries(
|
||||
state.settings.map((setting) => [setting.id, setting.value]),
|
||||
),
|
||||
},
|
||||
persistence,
|
||||
history: "replay",
|
||||
});
|
||||
},
|
||||
bind,
|
||||
);
|
||||
bind(bridge);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -1157,16 +1195,32 @@ class PluginAgentSession implements AgentSession {
|
||||
private readonly revertTokens = new Map<string, ProviderTimelineItem["revertToken"]>();
|
||||
private readonly timelineSnapshots = new Map<string, TimelineTextSnapshot>();
|
||||
private readonly subagentIdsBySession = new Map<string, string | null>();
|
||||
private readonly childUnsubscribes = new Map<string, () => void>();
|
||||
private readonly children = new Map<
|
||||
string,
|
||||
{ session: ProviderRuntimeSession; unsubscribe: () => void }
|
||||
>();
|
||||
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
private currentTurnId: string | null = null;
|
||||
private closed = false;
|
||||
private replay: {
|
||||
persistence: ProviderPersistence;
|
||||
config: ProviderConfigState;
|
||||
messageId: string;
|
||||
scope: "conversation" | "both";
|
||||
closed: boolean;
|
||||
} | null = null;
|
||||
private replaying: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
readonly provider: string,
|
||||
private readonly bridge: ProviderRuntimeSession,
|
||||
private bridge: ProviderRuntimeSession,
|
||||
private readonly onClose: () => void,
|
||||
private readonly reopen: (
|
||||
persistence: ProviderPersistence,
|
||||
state: ProviderConfigState,
|
||||
) => Promise<ProviderRuntimeSession>,
|
||||
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
|
||||
) {
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
@@ -1183,7 +1237,13 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
get capabilities(): AgentCapabilityFlags {
|
||||
return agentCapabilities(this.bridge.negotiatedCapabilities);
|
||||
const capabilities = agentCapabilities(this.bridge.negotiatedCapabilities);
|
||||
const resumable = this.canReplayHistory;
|
||||
return {
|
||||
...capabilities,
|
||||
supportsRewindConversation: capabilities.supportsRewindConversation && resumable,
|
||||
supportsRewindBoth: capabilities.supportsRewindBoth && resumable,
|
||||
};
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
@@ -1211,6 +1271,7 @@ class PluginAgentSession implements AgentSession {
|
||||
prompt: AgentPromptInput,
|
||||
options: AgentRunOptions = {},
|
||||
): Promise<{ turnId: string }> {
|
||||
this.assertReplayComplete();
|
||||
const clientMessageId = options.clientMessageId ?? randomUUID();
|
||||
const result = await this.bridge.prompt({
|
||||
clientMessageId,
|
||||
@@ -1233,6 +1294,7 @@ class PluginAgentSession implements AgentSession {
|
||||
prompt: AgentPromptInput,
|
||||
options: SteerActiveTurnOptions,
|
||||
): Promise<SteerResult> {
|
||||
this.assertReplayComplete();
|
||||
if (!this.bridge.negotiatedCapabilities.includes("prompt.steer")) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
@@ -1256,6 +1318,7 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.ensureReplay();
|
||||
for (const event of this.history.read(this.provider)) yield event;
|
||||
}
|
||||
|
||||
@@ -1283,6 +1346,7 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({ mode: modeId });
|
||||
}
|
||||
|
||||
@@ -1319,17 +1383,10 @@ class PluginAgentSession implements AgentSession {
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
for (const unsubscribe of this.childUnsubscribes.values()) unsubscribe();
|
||||
this.childUnsubscribes.clear();
|
||||
this.subagentIdsBySession.clear();
|
||||
this.history.clear();
|
||||
this.timelineSnapshots.clear();
|
||||
this.childSnapshots.clear();
|
||||
this.revertTokens.clear();
|
||||
this.pendingPermissions.clear();
|
||||
this.permissionResponses.clear();
|
||||
this.listeners.clear();
|
||||
await this.replaying?.catch(() => undefined);
|
||||
await this.clearBinding();
|
||||
this.replay = null;
|
||||
this.listeners.clear();
|
||||
this.onClose();
|
||||
await this.bridge.close();
|
||||
@@ -1344,14 +1401,17 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({ model: modelId });
|
||||
}
|
||||
|
||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({ thinkingOption: thinkingOptionId });
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({
|
||||
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
|
||||
});
|
||||
@@ -1415,10 +1475,10 @@ class PluginAgentSession implements AgentSession {
|
||||
const snapshots = new Map<string, TimelineTextSnapshot>();
|
||||
this.childSnapshots.set(childId, snapshots);
|
||||
for (const event of child.history) this.acceptChildEvent(childId, event, snapshots);
|
||||
this.childUnsubscribes.set(
|
||||
child.id,
|
||||
child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
|
||||
);
|
||||
this.children.set(child.id, {
|
||||
session: child,
|
||||
unsubscribe: child.onEvent((event) => this.acceptChildEvent(childId, event, snapshots)),
|
||||
});
|
||||
}
|
||||
|
||||
private accept(event: ProviderEvent, live: boolean): void {
|
||||
@@ -1636,7 +1696,84 @@ class PluginAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async clearBinding(): Promise<void> {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
for (const child of this.children.values()) {
|
||||
child.unsubscribe();
|
||||
await child.session.close();
|
||||
}
|
||||
this.children.clear();
|
||||
this.subagentIdsBySession.clear();
|
||||
this.history.clear();
|
||||
this.timelineSnapshots.clear();
|
||||
this.childSnapshots.clear();
|
||||
this.revertTokens.clear();
|
||||
this.pendingPermissions.clear();
|
||||
this.permissionResponses.clear();
|
||||
this.currentTurnId = null;
|
||||
}
|
||||
|
||||
private assertReplayComplete(): void {
|
||||
if (this.replay) {
|
||||
throw new Error(
|
||||
"Plugin rewind history refresh is incomplete. Retry the rewind or reload the agent before continuing.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// A native rewind has already committed. Until replay succeeds, neither history
|
||||
// nor a new prompt may use the retired binding. Keep its latest handle for retry.
|
||||
private async ensureReplay(): Promise<void> {
|
||||
if (!this.replay) return;
|
||||
if (this.closed) throw new StaleProviderSessionError(this.id);
|
||||
if (!this.replaying) {
|
||||
this.replaying = this.replayBinding().finally(() => {
|
||||
this.replaying = null;
|
||||
});
|
||||
}
|
||||
await this.replaying;
|
||||
}
|
||||
|
||||
private async replayBinding(): Promise<void> {
|
||||
const replay = this.replay!;
|
||||
if (!replay.closed) {
|
||||
for (const request of this.pendingPermissions.values()) {
|
||||
await this.respondToPermission(request.id, {
|
||||
behavior: "deny",
|
||||
message: "Session replaced after rewind",
|
||||
});
|
||||
this.pendingPermissions.delete(request.id);
|
||||
this.permissionResponses.delete(request.id);
|
||||
}
|
||||
await this.clearBinding();
|
||||
await this.bridge.close();
|
||||
replay.closed = true;
|
||||
}
|
||||
const bridge = await this.reopen(replay.persistence, replay.config);
|
||||
this.bridge = bridge;
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
this.unsubscribe = bridge.onEvent((event) => this.accept(event, true));
|
||||
this.onBind(bridge);
|
||||
this.replay = null;
|
||||
}
|
||||
|
||||
private get canReplayHistory(): boolean {
|
||||
return (
|
||||
this.bridge.restoration === "core" &&
|
||||
this.bridge.persistence !== null &&
|
||||
this.bridge.negotiatedCapabilities.includes("session.persistence")
|
||||
);
|
||||
}
|
||||
|
||||
private async revert(messageId: string, scope: "conversation" | "files" | "both") {
|
||||
const pending = this.replay;
|
||||
await this.ensureReplay();
|
||||
if (pending?.messageId === messageId && pending.scope === scope) return;
|
||||
if (scope !== "files" && !this.canReplayHistory) {
|
||||
throw new Error("Plugin conversation rewind requires session persistence for history replay");
|
||||
}
|
||||
const token = this.revertTokens.get(messageId);
|
||||
if (token === undefined) throw new Error(`No provider revert token for message ${messageId}`);
|
||||
await this.bridge.revert({
|
||||
@@ -1646,6 +1783,12 @@ class PluginAgentSession implements AgentSession {
|
||||
token,
|
||||
scope,
|
||||
});
|
||||
if (scope !== "files") {
|
||||
const persistence = this.bridge.persistence;
|
||||
if (!persistence) throw new Error("Plugin rewind removed session persistence");
|
||||
this.replay = { persistence, config: this.bridge.config, messageId, scope, closed: false };
|
||||
await this.ensureReplay();
|
||||
}
|
||||
}
|
||||
|
||||
private publish(event: AgentStreamEvent): void {
|
||||
|
||||
@@ -705,59 +705,3 @@ index a9c1804..f5a3c20 100644
|
||||
|
||||
return Insets(
|
||||
this._insets.top / density,
|
||||
diff --git a/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp b/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp
|
||||
index 01026dc..3786ec4 100644
|
||||
--- a/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp
|
||||
+++ b/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp
|
||||
@@ -17,13 +17,16 @@ void shadow::ShadowTreeManager::updateShadowTree(jsi::Runtime& rt) {
|
||||
}
|
||||
|
||||
#if REACT_NATIVE_VERSION_MINOR >= 81
|
||||
- std::unordered_map<Tag, folly::dynamic> tagToProps;
|
||||
+ // Paseo backport of https://github.com/jpudysz/react-native-unistyles/pull/1357.
|
||||
+ // UIManager::updateShadowTree commits a root cloned before the commit, so when a
|
||||
+ // window-size commit (rotation, keyboard) lands in between, the retried Unistyles
|
||||
+ // commit puts the previous layout constraints back and the app stays laid out for
|
||||
+ // the old size. Commit here instead and clone from the root the commit hands us.
|
||||
+ std::unordered_set<SurfaceId> affectedSurfaces;
|
||||
|
||||
for (const auto& [family, props] : updates) {
|
||||
auto safeProps = props.isObject() ? props : folly::dynamic::object();
|
||||
|
||||
- tagToProps.insert({family->getTag(), safeProps});
|
||||
-
|
||||
auto* mutableFamily = const_cast<ShadowNodeFamily*>(family);
|
||||
|
||||
if (mutableFamily->nativeProps_DEPRECATED && mutableFamily->nativeProps_DEPRECATED->isObject()) {
|
||||
@@ -31,9 +34,30 @@ void shadow::ShadowTreeManager::updateShadowTree(jsi::Runtime& rt) {
|
||||
} else {
|
||||
mutableFamily->nativeProps_DEPRECATED = std::make_unique<folly::dynamic>(safeProps);
|
||||
}
|
||||
+
|
||||
+ affectedSurfaces.insert(family->getSurfaceId());
|
||||
}
|
||||
|
||||
- UIManagerBinding::getBinding(rt)->getUIManager().updateShadowTree(std::move(tagToProps));
|
||||
+ const auto& shadowTreeRegistry = UIManagerBinding::getBinding(rt)->getUIManager().getShadowTreeRegistry();
|
||||
+
|
||||
+ shadowTreeRegistry.enumerate([&updates, &affectedSurfaces](const ShadowTree& shadowTree, bool&){
|
||||
+ if (!affectedSurfaces.contains(shadowTree.getSurfaceId())) {
|
||||
+ return;
|
||||
+ }
|
||||
+
|
||||
+ auto transaction = [&updates](const RootShadowNode& oldRootShadowNode) -> RootShadowNode::Unshared {
|
||||
+ auto affectedNodes = shadow::ShadowTreeManager::findAffectedNodes(oldRootShadowNode, updates);
|
||||
+
|
||||
+ return std::static_pointer_cast<RootShadowNode>(shadow::ShadowTreeManager::cloneShadowTree(
|
||||
+ oldRootShadowNode,
|
||||
+ updates,
|
||||
+ affectedNodes
|
||||
+ ));
|
||||
+ };
|
||||
+
|
||||
+ // mountSynchronously: this is an update from C++, not React
|
||||
+ shadowTree.commit(transaction, {false, true});
|
||||
+ });
|
||||
#else
|
||||
const auto& shadowTreeRegistry = UIManagerBinding::getBinding(rt)->getUIManager().getShadowTreeRegistry();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -32,11 +32,13 @@ describe("zai usage source", () => {
|
||||
process.env["USERPROFILE"] = homeDir;
|
||||
for (const key of [
|
||||
"APPDATA",
|
||||
"XDG_CONFIG_HOME",
|
||||
"COPILOT_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"GITHUB_PAT",
|
||||
"CURSOR_ACCESS_TOKEN",
|
||||
"CURSOR_TOKEN",
|
||||
"Z_AI_API_KEY",
|
||||
"ZAI_API_KEY",
|
||||
"GLM_API_KEY",
|
||||
"GROK_API_KEY",
|
||||
@@ -116,11 +118,61 @@ describe("zai usage source", () => {
|
||||
details: expect.arrayContaining([{ id: "status", label: "Status", value: "VALID" }]),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("discovery returns a locator when fetch finds zai credentials", async () => {
|
||||
const previous = process.env["ZAI_API_KEY"];
|
||||
try {
|
||||
it("fetches Z.ai usage from Z_AI_API_KEY, the name the glm-acp-agent CLI uses", async () => {
|
||||
process.env["Z_AI_API_KEY"] = "zai_cli_token";
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({ data: [{ productName: "GLM Coding Max", status: "VALID" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const zai = findProvider(await service().listUsage(), "zai");
|
||||
|
||||
expect(authorization).toBe("Bearer zai_cli_token");
|
||||
expect(zai).toMatchObject({ status: "available", planLabel: "GLM Coding Max" });
|
||||
});
|
||||
|
||||
it("reads Z.ai usage from the credentials file the glm-acp-agent CLI writes", async () => {
|
||||
const configDir = join(homeDir, ".config", "glm-acp-agent");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(configDir, "credentials.json"),
|
||||
JSON.stringify({ z_ai_api_key: "zai_file_token" }),
|
||||
);
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({ data: [{ productName: "GLM Coding Max", status: "VALID" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const zai = findProvider(await service().listUsage(), "zai");
|
||||
|
||||
expect(authorization).toBe("Bearer zai_file_token");
|
||||
expect(zai).toMatchObject({ status: "available" });
|
||||
});
|
||||
|
||||
it("honours XDG_CONFIG_HOME for the credentials file", async () => {
|
||||
const configDir = join(homeDir, "xdg", "glm-acp-agent");
|
||||
mkdirSync(configDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(configDir, "credentials.json"),
|
||||
JSON.stringify({ z_ai_api_key: "zai_xdg_token" }),
|
||||
);
|
||||
process.env["XDG_CONFIG_HOME"] = join(homeDir, "xdg");
|
||||
let authorization: string | null = null;
|
||||
fetchApi = (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
authorization = (init?.headers as Record<string, string> | undefined)?.Authorization ?? null;
|
||||
return jsonResponse({ data: [{ productName: "GLM Coding Max", status: "VALID" }] });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const zai = findProvider(await service().listUsage(), "zai");
|
||||
|
||||
expect(authorization).toBe("Bearer zai_xdg_token");
|
||||
expect(zai).toMatchObject({ status: "available" });
|
||||
});
|
||||
|
||||
it("discovery returns a locator when fetch finds zai credentials", async () => {
|
||||
process.env["ZAI_API_KEY"] = "fixture-token";
|
||||
let requested = false;
|
||||
await fetchFirst(async () => {
|
||||
@@ -131,10 +183,7 @@ it("discovery returns a locator when fetch finds zai credentials", async () => {
|
||||
expect(await discover()).toEqual([
|
||||
{ key: "default", input: { store: "env", locator: "ZAI_API_KEY" } },
|
||||
]);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env["ZAI_API_KEY"];
|
||||
else process.env["ZAI_API_KEY"] = previous;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("account discovery", () => {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { UsageInput } from "../shared/input.js";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
unavailable,
|
||||
@@ -25,11 +28,16 @@ const ZaiUsageResponseSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** The credentials file the `glm-acp-agent` CLI writes. */
|
||||
const ZaiCredentialsSchema = z.object({
|
||||
z_ai_api_key: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function fetchUsage(
|
||||
input: UsageInput,
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageReport> {
|
||||
const token = process.env[input.locator];
|
||||
const token = await readToken(input);
|
||||
if (!token) throw new Error("Z.ai login store no longer exists");
|
||||
|
||||
const res = await fetchApi("https://api.z.ai/api/biz/subscription/list", {
|
||||
@@ -64,9 +72,36 @@ export async function fetchUsage(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Z.AI API key comes from the environment or from the credentials file the
|
||||
* `glm-acp-agent` CLI writes. The CLI names the variable `Z_AI_API_KEY`; the
|
||||
* older `ZAI_API_KEY` spelling is still accepted.
|
||||
*/
|
||||
async function readToken(input: UsageInput): Promise<string | undefined> {
|
||||
if (input.store === "env") {
|
||||
return process.env[input.locator];
|
||||
}
|
||||
try {
|
||||
const raw: unknown = JSON.parse(await fs.readFile(input.locator, "utf8"));
|
||||
return ZaiCredentialsSchema.parse(raw).z_ai_api_key;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function discover(): Promise<UsageAccount[]> {
|
||||
for (const locator of ["ZAI_API_KEY", "GLM_API_KEY"]) {
|
||||
for (const locator of ["Z_AI_API_KEY", "ZAI_API_KEY", "GLM_API_KEY"]) {
|
||||
if (process.env[locator]) return [{ key: "default", input: { store: "env", locator } }];
|
||||
}
|
||||
|
||||
const credentialsPath = join(
|
||||
process.env.XDG_CONFIG_HOME || join(homedir(), ".config"),
|
||||
"glm-acp-agent",
|
||||
"credentials.json",
|
||||
);
|
||||
if (await readToken({ store: "file", locator: credentialsPath })) {
|
||||
return [{ key: "default", input: { store: "file", locator: credentialsPath } }];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
export const inputSchema = z
|
||||
.object({
|
||||
store: z.literal("env"),
|
||||
store: z.enum(["env", "file"]),
|
||||
locator: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@@ -287,6 +287,11 @@ Paseo refreshes an agent by closing its current provider session and opening it
|
||||
configuration and persistence. Re-read credentials, environment, global configuration, and MCP
|
||||
servers during `session.open`. There is no separate reload operation.
|
||||
|
||||
Conversation and combined rewind require a core-restored session with persistence. After a
|
||||
successful rewind, Paseo closes the session and reopens its latest persistence value with
|
||||
`history: "replay"`. Publish the retained native branch before `session.ready`, including its
|
||||
children. File-only rewind keeps the current session and conversation history.
|
||||
|
||||
Use `restoration: "core"` when Paseo can reopen a session from persistence. A provider-owned child
|
||||
uses `restoration: "parent"`; emit it as another `session.opened` event with `parentSessionId` and
|
||||
recreate it while restoring the parent.
|
||||
|
||||
@@ -28,9 +28,6 @@ const patchedPackages = [
|
||||
// withUnistyles: also keep the uniProps filter until upstream consumes its control prop.
|
||||
// Android configuration: backport 3.5.3; remove the two NativePlatform hunks at >= 3.5.3.
|
||||
// https://github.com/jpudysz/react-native-unistyles/compare/v3.5.2...v3.5.3
|
||||
// Layout constraints: backport of https://github.com/jpudysz/react-native-unistyles/pull/1357
|
||||
// (commit from the root the commit hands us, so a concurrent window-size commit is kept).
|
||||
// Remove the ShadowTreeManager hunk when the installed release includes that PR.
|
||||
{
|
||||
nodeModulesPath: "node_modules/react-native-unistyles",
|
||||
patchPrefix: "react-native-unistyles+",
|
||||
|
||||
Reference in New Issue
Block a user