mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-13 11:55:03 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bc78b951b | ||
|
|
e2f31e5db2 | ||
|
|
af6ca46ae3 |
@@ -306,6 +306,11 @@ export interface PaseoAgentTimelineRefetchOptions {
|
||||
cursor?: FetchAgentTimelineCursor;
|
||||
limit?: number;
|
||||
projection?: FetchAgentTimelineProjection;
|
||||
/**
|
||||
* Extends a `tail` or `before` page back to the start of the oldest turn it touches, so
|
||||
* `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn.
|
||||
*/
|
||||
wholeTurns?: boolean;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -393,6 +393,7 @@ describe("lifecycle hook SDK types", () => {
|
||||
expect(
|
||||
typeErrors(`
|
||||
import type { PluginServerContext, PluginHooks, PluginHookPayload, PluginHookResult } from "@getpaseo/plugin/server";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
export default function contribute(server: PluginServerContext) {
|
||||
server.addHook("worktree.create.before", async ({ request }) => {
|
||||
if (request.kind === "checkout-branch") {
|
||||
@@ -426,8 +427,17 @@ describe("lifecycle hook SDK types", () => {
|
||||
const { metadata, ...withoutMetadata } = request;
|
||||
return withoutMetadata;
|
||||
});
|
||||
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
|
||||
server.addHook("agent.turn.after", async (event, { paseo, signal }) => {
|
||||
const { agent, outcome } = event;
|
||||
const kind: "completed" | "failed" | "canceled" = outcome.kind;
|
||||
// @ts-expect-error Turn payloads carry no history; read it from the agent's timeline.
|
||||
event.timeline;
|
||||
const turn = await paseo.agents
|
||||
.ref(agent.id)
|
||||
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const items: readonly AgentTimelineItem[] = turn.entries
|
||||
.filter((entry) => entry.turnId === event.turnId)
|
||||
.map(({ item }) => item);
|
||||
// @ts-expect-error Notification payloads have no creation request.
|
||||
outcome.request;
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { AgentToolDefinition } from "./agent-tools.js";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentTimelineItem,
|
||||
AgentSessionConfig,
|
||||
JsonValue,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
@@ -104,12 +103,13 @@ export interface PluginHookEvents {
|
||||
"agent.turn.after": {
|
||||
agent: PluginHookAgent;
|
||||
turnId: string | null;
|
||||
outcome: PluginTurnOutcome;
|
||||
/**
|
||||
* @deprecated Always empty. Fetch history on demand with
|
||||
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
|
||||
* The payload carries no history. Read the turn with
|
||||
* `paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true })`
|
||||
* and keep the entries whose `turnId` equals this `turnId`. The result is empty when a newer turn
|
||||
* started before the read, or when `turnId` is `null`.
|
||||
*/
|
||||
timeline: readonly AgentTimelineItem[];
|
||||
outcome: PluginTurnOutcome;
|
||||
};
|
||||
"agent.permission.before": { agent: PluginHookAgent; request: AgentPermissionRequest };
|
||||
"agent.permission.after": {
|
||||
|
||||
@@ -13334,7 +13334,6 @@ test.each([
|
||||
},
|
||||
turnId: "hook-turn",
|
||||
outcome,
|
||||
timeline: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
type CheckoutDiffCompare,
|
||||
} from "../../utils/checkout-git.js";
|
||||
import { lstat, readFile, readlink } from "node:fs/promises";
|
||||
import type { BigIntStats } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type { DiffEndpoint, DiffSubscribeRequest } from "@getpaseo/protocol/messages";
|
||||
import type { DiffEntry, DiffBody } from "@getpaseo/protocol/binary-frames/index";
|
||||
@@ -27,6 +26,16 @@ export interface ListedFile {
|
||||
identity: string;
|
||||
source: { kind: "tracked" } | { kind: "untracked"; size: number };
|
||||
}
|
||||
interface WorktreeInput {
|
||||
/** Comparison-side mode, or null when the file is added/untracked on this side. */
|
||||
oldMode: string | null;
|
||||
/** Comparison-side blob, or null when the file is added/untracked on this side. */
|
||||
oldBlob: string | null;
|
||||
/** A regular file whose bytes readGitInputs blob-hashes. */
|
||||
hashed: boolean;
|
||||
/** Content Git cannot read as a regular file, such as a symlink target or submodule state. */
|
||||
literal: string | null;
|
||||
}
|
||||
interface ResolvedComparison {
|
||||
baseSha: string;
|
||||
headSha: string;
|
||||
@@ -195,20 +204,47 @@ async function readComparisonList(
|
||||
...(privateIndex ? { GIT_INDEX_FILE: privateIndex.indexFile } : {}),
|
||||
},
|
||||
};
|
||||
const { files, worktreeFiles, interpretationPaths } = await readTrackedFiles(
|
||||
compare,
|
||||
args,
|
||||
options,
|
||||
);
|
||||
if (compare.head.kind === "worktree") files.push(...(await listUntrackedFiles(options)));
|
||||
const tracked = await readTrackedFiles(compare, args, options);
|
||||
const files = tracked.files;
|
||||
const worktreeInputs = tracked.worktreeInputs;
|
||||
if (compare.head.kind === "worktree") {
|
||||
const untracked = await listUntrackedFiles(options);
|
||||
files.push(...untracked.files);
|
||||
for (const [path, input] of untracked.worktreeInputs) worktreeInputs.set(path, input);
|
||||
}
|
||||
files.sort((a, b) => a.entry.path.localeCompare(b.entry.path));
|
||||
for (const file of files) interpretationPaths.add(file.entry.path);
|
||||
const gitInputs = await readGitInputs([...interpretationPaths].sort(), worktreeFiles, options);
|
||||
for (const file of files)
|
||||
for (const file of files) tracked.interpretationPaths.add(file.entry.path);
|
||||
const hashedPaths = new Set(
|
||||
[...worktreeInputs].filter(([, input]) => input.hashed).map(([path]) => path),
|
||||
);
|
||||
const literalContent = new Map(
|
||||
[...worktreeInputs].flatMap(([path, input]) =>
|
||||
input.literal === null ? [] : [[path, input.literal] as const],
|
||||
),
|
||||
);
|
||||
const gitInputs = await readGitInputs({
|
||||
paths: [...tracked.interpretationPaths].sort(),
|
||||
hashedPaths,
|
||||
options,
|
||||
literalContent,
|
||||
});
|
||||
for (const file of files) {
|
||||
if (compare.head.kind === "worktree") {
|
||||
const input = worktreeInputs.get(file.entry.path);
|
||||
if (!input) throw new Error(`Missing worktree identity for ${file.entry.path}`);
|
||||
file.identity = JSON.stringify([
|
||||
input.oldMode,
|
||||
input.oldBlob,
|
||||
gitInputs.identities.get(file.entry.path) ?? null,
|
||||
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path) ?? null,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
file.identity += JSON.stringify([
|
||||
gitInputs.identities.get(file.entry.path),
|
||||
gitInputs.identities.get(file.entry.oldPath ?? file.entry.path),
|
||||
]);
|
||||
}
|
||||
const metrics = { highlightCalls: 0 };
|
||||
const patches = new Patches(args, options, files);
|
||||
const sources = new HighlightSources({
|
||||
@@ -237,7 +273,15 @@ async function readComparisonList(
|
||||
const result =
|
||||
file.source.kind === "untracked"
|
||||
? await runGitCommand(
|
||||
["diff", "--no-index", ...GIT_PATCH_FORMAT_ARGS, "--", "/dev/null", file.entry.path],
|
||||
[
|
||||
"diff",
|
||||
"--no-index",
|
||||
"--no-textconv",
|
||||
...GIT_PATCH_FORMAT_ARGS,
|
||||
"--",
|
||||
"/dev/null",
|
||||
file.entry.path,
|
||||
],
|
||||
{
|
||||
...options,
|
||||
acceptExitCodes: [0, 1],
|
||||
@@ -293,7 +337,11 @@ async function readTrackedFiles(
|
||||
compare: Compare,
|
||||
args: string[],
|
||||
options: GitCommandOptions,
|
||||
): Promise<{ files: ListedFile[]; worktreeFiles: Set<string>; interpretationPaths: Set<string> }> {
|
||||
): Promise<{
|
||||
files: ListedFile[];
|
||||
worktreeInputs: Map<string, WorktreeInput>;
|
||||
interpretationPaths: Set<string>;
|
||||
}> {
|
||||
const [raw, stat] = await Promise.all([
|
||||
runGitCommand([...args.filter((arg) => arg !== "-w"), "--raw", "--no-abbrev", "-z"], options),
|
||||
runGitCommand([...args, "--numstat", "-z"], options),
|
||||
@@ -303,7 +351,7 @@ async function readTrackedFiles(
|
||||
const stats = parseStats(stat.stdout);
|
||||
const fields = raw.stdout.split("\0");
|
||||
const files: ListedFile[] = [];
|
||||
const worktreeFiles = new Set<string>();
|
||||
const worktreeInputs = new Map<string, WorktreeInput>();
|
||||
const interpretationPaths = new Set<string>();
|
||||
for (let index = 0; index < fields.length - 1; index++) {
|
||||
const header = fields[index].split(" ");
|
||||
@@ -325,10 +373,25 @@ async function readTrackedFiles(
|
||||
else if (code === "D") change = "deleted";
|
||||
else if (oldPath !== null) change = "renamed";
|
||||
let identity = header.join(" ");
|
||||
if (compare.head.kind === "worktree" && code !== "D") {
|
||||
const worktree = await readWorktreeIdentity(path, header, options);
|
||||
identity += `:${worktree.identity}`;
|
||||
if (worktree.regular && !counts.binary) worktreeFiles.add(path);
|
||||
if (compare.head.kind === "worktree") {
|
||||
const input: WorktreeInput = {
|
||||
oldMode: code === "A" ? null : header[0],
|
||||
oldBlob: code === "A" ? null : absentBlob(header[2]),
|
||||
hashed: false,
|
||||
literal: null,
|
||||
};
|
||||
if (code !== "D") {
|
||||
const worktree = await readWorktreeContent({
|
||||
path,
|
||||
header,
|
||||
text: !counts.binary,
|
||||
options,
|
||||
});
|
||||
input.hashed = worktree.hashed;
|
||||
input.literal = worktree.literal;
|
||||
}
|
||||
worktreeInputs.set(path, input);
|
||||
identity = "";
|
||||
}
|
||||
|
||||
files.push({
|
||||
@@ -345,24 +408,32 @@ async function readTrackedFiles(
|
||||
source: { kind: "tracked" },
|
||||
});
|
||||
}
|
||||
return { files, worktreeFiles, interpretationPaths };
|
||||
return { files, worktreeInputs, interpretationPaths };
|
||||
}
|
||||
|
||||
async function readWorktreeIdentity(
|
||||
path: string,
|
||||
header: string[],
|
||||
options: GitCommandOptions,
|
||||
): Promise<{ identity: string; regular: boolean }> {
|
||||
const value = await lstat(resolve(options.cwd, path), { bigint: true });
|
||||
let identity = fileStatIdentity(value);
|
||||
async function readWorktreeContent({
|
||||
path,
|
||||
header,
|
||||
text,
|
||||
options,
|
||||
}: {
|
||||
path: string;
|
||||
header: string[];
|
||||
text: boolean;
|
||||
options: GitCommandOptions;
|
||||
}): Promise<{ hashed: boolean; literal: string | null }> {
|
||||
const absolutePath = resolve(options.cwd, path);
|
||||
const value = await lstat(absolutePath, { bigint: true });
|
||||
if (value.isSymbolicLink())
|
||||
return { hashed: false, literal: `symlink:${await readlink(absolutePath)}` };
|
||||
if (value.isDirectory() && (header[0] === ":160000" || header[1] === "160000")) {
|
||||
const submodule = await runGitCommand(
|
||||
["status", "--porcelain=v2", "--branch", "--untracked-files=normal"],
|
||||
{ cwd: resolve(options.cwd, path), envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
|
||||
{ cwd: absolutePath, envOverlay: { GIT_OPTIONAL_LOCKS: "0" } },
|
||||
);
|
||||
identity += `:${submodule.stdout}`;
|
||||
return { hashed: false, literal: `submodule:${submodule.stdout}` };
|
||||
}
|
||||
return { identity, regular: value.isFile() };
|
||||
return { hashed: value.isFile() && text, literal: null };
|
||||
}
|
||||
|
||||
function diffArguments(compare: Compare, baseSha: string, headSha: string): string[] {
|
||||
@@ -385,9 +456,12 @@ async function readUntrackedText(
|
||||
return readFile(path);
|
||||
}
|
||||
|
||||
async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFile[]> {
|
||||
async function listUntrackedFiles(
|
||||
options: GitCommandOptions,
|
||||
): Promise<{ files: ListedFile[]; worktreeInputs: Map<string, WorktreeInput> }> {
|
||||
const cwd = options.cwd;
|
||||
const files: ListedFile[] = [];
|
||||
const worktreeInputs = new Map<string, WorktreeInput>();
|
||||
const untracked = await runGitCommand(
|
||||
["ls-files", "--others", "--exclude-standard", "-z"],
|
||||
options,
|
||||
@@ -408,6 +482,12 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
|
||||
binary || content.length === 0
|
||||
? 0
|
||||
: content.toString("utf8").split("\n").length - Number(content.at(-1) === 10);
|
||||
worktreeInputs.set(path, {
|
||||
oldMode: null,
|
||||
oldBlob: null,
|
||||
hashed: !symlink && !binary && !omitted,
|
||||
literal: symlink ? `symlink:${content.toString("utf8")}` : null,
|
||||
});
|
||||
files.push({
|
||||
entry: {
|
||||
path,
|
||||
@@ -418,14 +498,13 @@ async function listUntrackedFiles(options: GitCommandOptions): Promise<ListedFil
|
||||
deletions: 0,
|
||||
body: !binary,
|
||||
},
|
||||
identity: fileStatIdentity(value),
|
||||
identity: "",
|
||||
source: { kind: "untracked", size: Number(value.size) },
|
||||
});
|
||||
}
|
||||
return files;
|
||||
return { files, worktreeInputs };
|
||||
}
|
||||
|
||||
function fileStatIdentity(value: BigIntStats): string {
|
||||
// Timestamp-preserving copies still change file status or replace the inode.
|
||||
return `${value.dev}:${value.ino}:${value.size}:${value.mtimeNs}:${value.ctimeNs}`;
|
||||
function absentBlob(value: string): string | null {
|
||||
return /^0+$/.test(value) ? null : value;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,20 @@ interface Interpretation {
|
||||
|
||||
// Reuse depends on Git's interpretation, not just filesystem metadata. Ask Git
|
||||
// for effective attributes/config and filtered blob identities without writing objects.
|
||||
export async function readGitInputs(
|
||||
paths: string[],
|
||||
worktreeFiles: Set<string>,
|
||||
options: GitCommandOptions,
|
||||
) {
|
||||
const regular = paths.filter((path) => worktreeFiles.has(path));
|
||||
interface ReadGitInputsOptions {
|
||||
paths: string[];
|
||||
hashedPaths: ReadonlySet<string>;
|
||||
options: GitCommandOptions;
|
||||
literalContent?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export async function readGitInputs({
|
||||
paths,
|
||||
hashedPaths,
|
||||
options,
|
||||
literalContent = new Map(),
|
||||
}: ReadGitInputsOptions) {
|
||||
const regular = paths.filter((path) => hashedPaths.has(path));
|
||||
const [interpretation, blobs] = await Promise.all([
|
||||
readInterpretation(paths, options),
|
||||
regular.length
|
||||
@@ -34,7 +42,7 @@ export async function readGitInputs(
|
||||
fingerprint([
|
||||
interpretation.config,
|
||||
interpretation.attributes.get(path) ?? [],
|
||||
blobByPath.get(path) ?? null,
|
||||
blobByPath.get(path) ?? literalContent.get(path) ?? null,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -530,6 +530,222 @@ test("submodule commits invalidate cached worktree gitlink bodies", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("stat-only changes reuse cached bodies and send no body frames", async () => {
|
||||
const { utimes } = await import("node:fs/promises");
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-stat-only-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const streams = new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch(root) {
|
||||
return { repoRoot: root, unsubscribe: async () => {} };
|
||||
},
|
||||
});
|
||||
const frames: DiffFrame[] = [];
|
||||
const path = join(cwd, "file.txt");
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
await writeFile(join(cwd, "README"), "base\n");
|
||||
await writeFile(path, "base\n");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
|
||||
await writeFile(path, "changed\n");
|
||||
const subscription = await streams.subscribe(
|
||||
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
|
||||
async (frame) => {
|
||||
frames.push(frame);
|
||||
},
|
||||
);
|
||||
await subscription.start();
|
||||
expect(
|
||||
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
|
||||
).toContainEqual(expect.objectContaining({ path: "file.txt" }));
|
||||
frames.length = 0;
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const ended = frames.filter((frame) => frame.opcode === DiffOpcode.End).length;
|
||||
const timestamp = new Date(Date.now() + 1000 * (index + 1));
|
||||
await utimes(path, timestamp, timestamp);
|
||||
await streams.invalidateForCwd(cwd);
|
||||
await expect
|
||||
.poll(() => frames.filter((frame) => frame.opcode === DiffOpcode.End).length, {
|
||||
timeout: 5000,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(ended + 1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
|
||||
const beginnings = frames.filter((frame) => frame.opcode === DiffOpcode.Begin);
|
||||
expect(beginnings.length).toBeGreaterThanOrEqual(3);
|
||||
for (const begin of beginnings) {
|
||||
expect(begin.entries.find((entry) => entry.path === "file.txt")?.body).toBe(false);
|
||||
}
|
||||
await subscription.release();
|
||||
} finally {
|
||||
await streams.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("staging unchanged worktree content reuses its cached body", async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-staging-only-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const streams = new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch(root) {
|
||||
return { repoRoot: root, unsubscribe: async () => {} };
|
||||
},
|
||||
});
|
||||
const frames: DiffFrame[] = [];
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
await writeFile(join(cwd, "README"), "base\n");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
|
||||
await writeFile(join(cwd, "staged.ts"), "export const staged = true;\n");
|
||||
const subscription = await streams.subscribe(
|
||||
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
|
||||
async (frame) => {
|
||||
frames.push(frame);
|
||||
},
|
||||
);
|
||||
await subscription.start();
|
||||
expect(
|
||||
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
|
||||
).toContainEqual(expect.objectContaining({ path: "staged.ts" }));
|
||||
frames.length = 0;
|
||||
git("add", "-A");
|
||||
await streams.invalidateForCwd(cwd);
|
||||
await expect
|
||||
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
|
||||
.toBe(true);
|
||||
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
|
||||
expect(frames.find((frame) => frame.opcode === DiffOpcode.Begin)).toMatchObject({
|
||||
entries: [{ path: "staged.ts", change: "added", body: false }],
|
||||
});
|
||||
await subscription.release();
|
||||
} finally {
|
||||
await streams.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"untracked bodies use the same patch conversion as tracked files",
|
||||
async () => {
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-textconv-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const streams = new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch(root) {
|
||||
return { repoRoot: root, unsubscribe: async () => {} };
|
||||
},
|
||||
});
|
||||
const frames: DiffFrame[] = [];
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
await writeFile(join(cwd, ".gitattributes"), "*.dat diff=upper\n");
|
||||
git("config", "diff.upper.textconv", "sed s/hello/HELLO/");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "base");
|
||||
await writeFile(join(cwd, "x.dat"), "hello\n");
|
||||
const subscription = await streams.subscribe(
|
||||
{ cwd, base: { kind: "rev", rev: "HEAD" }, head: { kind: "worktree" } },
|
||||
async (frame) => {
|
||||
frames.push(frame);
|
||||
},
|
||||
);
|
||||
await subscription.start();
|
||||
const initial = frames
|
||||
.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : []))
|
||||
.find((file) => file.path === "x.dat");
|
||||
expect(initial?.hunks.flatMap((hunk) => hunk.lines)).toContainEqual({
|
||||
type: "add",
|
||||
content: "hello",
|
||||
});
|
||||
frames.length = 0;
|
||||
git("add", "x.dat");
|
||||
await streams.invalidateForCwd(cwd);
|
||||
await expect
|
||||
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
|
||||
.toBe(true);
|
||||
expect(frames.filter((frame) => frame.opcode === DiffOpcode.Bodies)).toHaveLength(0);
|
||||
await subscription.release();
|
||||
} finally {
|
||||
await streams.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"comparison-side file type changes refresh cached bodies",
|
||||
async () => {
|
||||
const { symlink } = await import("node:fs/promises");
|
||||
const cwd = await mkdtemp(join(tmpdir(), "diff-base-mode-"));
|
||||
const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
|
||||
const streams = new DiffSubscriptions({
|
||||
async requestWorkingTreeWatch(root) {
|
||||
return { repoRoot: root, unsubscribe: async () => {} };
|
||||
},
|
||||
});
|
||||
const frames: DiffFrame[] = [];
|
||||
const path = join(cwd, "x");
|
||||
try {
|
||||
git("init", "-b", "main");
|
||||
git("config", "user.name", "Test");
|
||||
git("config", "user.email", "test@example.com");
|
||||
await writeFile(path, "target");
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "regular");
|
||||
git("branch", "compare");
|
||||
await rm(path);
|
||||
await symlink("target", path);
|
||||
git("add", ".");
|
||||
git("-c", "commit.gpgsign=false", "commit", "-m", "symlink");
|
||||
const moved = git("rev-parse", "HEAD");
|
||||
await rm(path);
|
||||
await writeFile(path, "target\n");
|
||||
const compare = {
|
||||
cwd,
|
||||
base: { kind: "rev" as const, rev: "refs/heads/compare" },
|
||||
head: { kind: "worktree" as const },
|
||||
};
|
||||
const subscription = await streams.subscribe(compare, async (frame) => {
|
||||
frames.push(frame);
|
||||
});
|
||||
await subscription.start();
|
||||
expect(
|
||||
frames.flatMap((frame) => (frame.opcode === DiffOpcode.Bodies ? frame.files : [])),
|
||||
).toContainEqual(expect.objectContaining({ path: "x" }));
|
||||
frames.length = 0;
|
||||
git("update-ref", "refs/heads/compare", moved);
|
||||
await streams.invalidateForCwd(cwd);
|
||||
await expect
|
||||
.poll(() => frames.some((frame) => frame.opcode === DiffOpcode.End), { timeout: 5000 })
|
||||
.toBe(true);
|
||||
const bodies = frames.flatMap((frame) =>
|
||||
frame.opcode === DiffOpcode.Bodies ? frame.files : [],
|
||||
);
|
||||
expect(bodies).toContainEqual(expect.objectContaining({ path: "x" }));
|
||||
const { listComparison } = await import("./git.js");
|
||||
const fresh = await listComparison(compare);
|
||||
try {
|
||||
const record = await fresh.read(fresh.files[0]);
|
||||
if (record.kind !== "ready") throw new Error("Expected readable type change");
|
||||
expect(bodies).toContainEqual(record.body);
|
||||
} finally {
|
||||
await fresh.close();
|
||||
}
|
||||
await subscription.release();
|
||||
} finally {
|
||||
await streams.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each([true, false])(
|
||||
"tracked=%s timestamp-preserving replacements refresh cached bodies",
|
||||
async (tracked) => {
|
||||
|
||||
@@ -163,8 +163,14 @@ export default function contribute(server) {
|
||||
server.addHook("agent.turn.before", async (event) => {
|
||||
console.log(JSON.stringify({ hook: "agent.turn.before", event }));
|
||||
});
|
||||
server.addHook("agent.turn.after", async (event) => {
|
||||
console.log(JSON.stringify({ hook: "agent.turn.after", event }));
|
||||
server.addHook("agent.turn.after", async (event, context) => {
|
||||
const page = await context.paseo.agents
|
||||
.ref(event.agent.id)
|
||||
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const turn = page.entries
|
||||
.filter((entry) => entry.turnId === event.turnId)
|
||||
.map(({ item }) => item.type);
|
||||
console.log(JSON.stringify({ hook: "agent.turn.after", event, turn }));
|
||||
});
|
||||
server.addHook("agent.permission.before", async (event, context) => {
|
||||
console.log(JSON.stringify({ hook: "agent.permission.before", event }));
|
||||
@@ -220,6 +226,7 @@ export default function contribute(server) {
|
||||
{
|
||||
hook: "agent.turn.after",
|
||||
event: { outcome: { kind: "completed" }, timeline: [] },
|
||||
turn: ["user_message", "tool_call"],
|
||||
},
|
||||
{ hook: "agent.turn.before", event: { agent: { id: agent.id } } },
|
||||
]);
|
||||
|
||||
@@ -157,8 +157,7 @@ test.skipIf(process.platform !== "linux")(
|
||||
"This is a hook integration test. Reply exactly: out of credits. If the next user message is Try again., reply exactly RETRY_OK instead. Do not use tools.",
|
||||
);
|
||||
await waitForTurns(client, agent.id, 2);
|
||||
const retryTurns = await endedTurns(client, agent.id);
|
||||
expect(assistantText(retryTurns[1].data.timeline)).toBe("RETRY_OK");
|
||||
expect(await latestReplyText(client, agent.id)).toBe("RETRY_OK");
|
||||
proof.followup = "RETRY_OK";
|
||||
|
||||
await client.sendMessage(
|
||||
@@ -207,8 +206,7 @@ test.skipIf(process.platform !== "linux")(
|
||||
const envPermission = await waitForPermission(client, agent.id, envCommand);
|
||||
await client.respondToPermission(agent.id, envPermission, { behavior: "allow" });
|
||||
await waitForTurns(client, agent.id, 5);
|
||||
const envTurns = await endedTurns(client, agent.id);
|
||||
expect(assistantText(envTurns[4].data.timeline)).toBe("created|opened");
|
||||
expect(await latestReplyText(client, agent.id)).toBe("created|opened");
|
||||
proof.environmentOutput = "created|opened";
|
||||
|
||||
await client.refreshAgent(agent.id);
|
||||
@@ -332,6 +330,16 @@ test.skipIf(process.platform !== "linux")(
|
||||
600_000,
|
||||
);
|
||||
|
||||
// Turn hooks carry no history; read the reply the way plugins do.
|
||||
async function latestReplyText(client: DaemonClient, agentId: string): Promise<string> {
|
||||
const page = await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
wholeTurns: true,
|
||||
});
|
||||
return assistantText(page.entries.map((entry) => entry.item));
|
||||
}
|
||||
|
||||
function assistantText(timeline: readonly AgentTimelineItem[]): string {
|
||||
let text = "";
|
||||
for (const item of timeline) {
|
||||
|
||||
@@ -195,21 +195,18 @@ export function publishAgentStream(
|
||||
lifecycle.emit("agent.turn.after", {
|
||||
agent,
|
||||
turnId: event.turnId ?? null,
|
||||
timeline: [],
|
||||
outcome: { kind: "completed" },
|
||||
});
|
||||
} else if (event.type === "turn_failed") {
|
||||
lifecycle.emit("agent.turn.after", {
|
||||
agent,
|
||||
turnId: event.turnId ?? null,
|
||||
timeline: [],
|
||||
outcome: { kind: "failed", error: { message: event.error, code: event.code } },
|
||||
});
|
||||
} else if (event.type === "turn_canceled") {
|
||||
lifecycle.emit("agent.turn.after", {
|
||||
agent,
|
||||
turnId: event.turnId ?? null,
|
||||
timeline: [],
|
||||
outcome: { kind: "canceled", reason: event.reason },
|
||||
});
|
||||
} else if (event.type === "permission_requested") {
|
||||
|
||||
@@ -564,7 +564,7 @@ export class PluginRuntime {
|
||||
type: "hook",
|
||||
requestId: randomUUID(),
|
||||
name,
|
||||
input: event,
|
||||
input: wireEvent(name, event),
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((error) => {
|
||||
@@ -1482,3 +1482,11 @@ function readConnectionId(value: unknown): string | null {
|
||||
const connectionId = Reflect.get(value, "connectionId");
|
||||
return typeof connectionId === "string" ? connectionId : null;
|
||||
}
|
||||
|
||||
// COMPAT(pluginTurnTimeline): added in v0.16.0-beta.3; plugins built before the field left the SDK type read `timeline`; remove after 2027-05-31
|
||||
function wireEvent<Name extends keyof PluginHookEvents>(
|
||||
name: Name,
|
||||
event: PluginHookEvents[Name],
|
||||
): PluginHookEvents[Name] {
|
||||
return name === "agent.turn.after" ? { ...event, timeline: [] } : event;
|
||||
}
|
||||
|
||||
@@ -23,8 +23,9 @@ export default function contribute(server: PluginServerContext) {
|
||||
return;
|
||||
}
|
||||
const agent = context.paseo.agents.ref(event.agent.id);
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
|
||||
const text = latestOutputText(page.entries.map(({ item }) => item));
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
|
||||
const text = latestOutputText(turn.map(({ item }) => item));
|
||||
if (/out of credits/i.test(text)) {
|
||||
console.log("Sending a follow-up after out of credits", event.agent.id);
|
||||
await agent.send("Try again.");
|
||||
|
||||
@@ -62,7 +62,7 @@ Paseo only sees the returned promise, so detached work gets no error reporting a
|
||||
| `agent.close.after` | no | `{ agent }` | |
|
||||
| `agent.archive.after` | no | `{ agent, archivedAt }` | |
|
||||
| `agent.turn.before` | no | `{ agent, turnId }` | |
|
||||
| `agent.turn.after` | no | `{ agent, turnId, outcome, timeline }` | |
|
||||
| `agent.turn.after` | no | `{ agent, turnId, outcome }` | |
|
||||
| `agent.permission.before` | no | `{ agent, request }` | |
|
||||
| `agent.permission.after` | no | `{ agent, requestId, resolution }` | |
|
||||
|
||||
@@ -525,7 +525,7 @@ server.addHook("agent.turn.before", async ({ agent, turnId }) => {
|
||||
|
||||
## agent.turn.after
|
||||
|
||||
A live turn completes, fails, or is canceled. Receives `{ agent, turnId, outcome, timeline }`. `outcome.kind` is `completed`, `failed`, or `canceled`. `timeline` is deprecated and always empty; fetch history through the SDK as below.
|
||||
A live turn completes, fails, or is canceled. Receives `{ agent, turnId, outcome }`. `outcome.kind` is `completed`, `failed`, or `canceled`. The payload carries no history. Read the turn from the agent's timeline: `refetch({ direction: "tail", limit: 1, wholeTurns: true })` returns the latest turn whole, as below. Keep the entries whose `turnId` equals `event.turnId`, because a turn that starts before your read, such as a follow-up another plugin sent, replaces this one as the latest. The filtered list is empty in that case, and when `event.turnId` is `null`. Plugins built against an older SDK that read `event.timeline` get an empty array.
|
||||
|
||||
### Send a follow-up when a turn ends
|
||||
|
||||
@@ -545,8 +545,9 @@ export default function contribute(server: PluginServerContext) {
|
||||
}
|
||||
|
||||
const agent = context.paseo.agents.ref(event.agent.id);
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
|
||||
const text = latestOutputText(page.entries.map(({ item }) => item));
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
|
||||
const text = latestOutputText(turn.map(({ item }) => item));
|
||||
if (/out of credits/i.test(text)) {
|
||||
await agent.send("Try again.");
|
||||
}
|
||||
@@ -562,9 +563,6 @@ Turn ends: "out of credits"
|
||||
→ a new turn starts
|
||||
```
|
||||
|
||||
The handler fetches only the last 20 timeline items. Output before that page is not inspected.
|
||||
Use `cursor` and `direction` to page further when your plugin needs more history.
|
||||
|
||||
This sends a new message with the existing SDK. Persistent matches keep sending follow-ups;
|
||||
add limits or delays in your plugin when needed. Attachments and tool effects are not replayed.
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没
|
||||
| `agent.close.after` | 否 | `{ agent }` | |
|
||||
| `agent.archive.after` | 否 | `{ agent, archivedAt }` | |
|
||||
| `agent.turn.before` | 否 | `{ agent, turnId }` | |
|
||||
| `agent.turn.after` | 否 | `{ agent, turnId, outcome, timeline }` | |
|
||||
| `agent.turn.after` | 否 | `{ agent, turnId, outcome }` | |
|
||||
| `agent.permission.before` | 否 | `{ agent, request }` | |
|
||||
| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | |
|
||||
|
||||
@@ -514,7 +514,7 @@ server.addHook("agent.turn.before", async ({ agent, turnId }) => {
|
||||
|
||||
## agent.turn.after {#agentturnafter}
|
||||
|
||||
实时轮次完成、失败或取消后触发,收到 `{ agent, turnId, outcome, timeline }`。`outcome.kind` 为 `completed`、`failed` 或 `canceled`。`timeline` 已弃用且始终为空;请按下面的示例通过 SDK 获取历史。
|
||||
实时轮次完成、失败或取消后触发,收到 `{ agent, turnId, outcome }`。`outcome.kind` 为 `completed`、`failed` 或 `canceled`。载荷不含历史。请从 Agent 的时间线读取本轮:`refetch({ direction: "tail", limit: 1, wholeTurns: true })` 会完整返回最近一轮,见下面的示例。只保留 `turnId` 等于 `event.turnId` 的条目:如果在读取前已有新一轮开始(例如另一个插件发送的后续消息),最近一轮就不再是本轮。此时过滤后的列表为空;`event.turnId` 为 `null` 时也为空。基于旧版 SDK 构建、仍读取 `event.timeline` 的插件只会拿到空数组。
|
||||
|
||||
### 在轮次结束时发送后续消息 {#send-a-follow-up-when-a-turn-ends}
|
||||
|
||||
@@ -531,8 +531,9 @@ export default function contribute(server: PluginServerContext) {
|
||||
}
|
||||
|
||||
const agent = context.paseo.agents.ref(event.agent.id);
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
|
||||
const text = latestOutputText(page.entries.map(({ item }) => item));
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const turn = page.entries.filter((entry) => entry.turnId === event.turnId);
|
||||
const text = latestOutputText(turn.map(({ item }) => item));
|
||||
if (/out of credits/i.test(text)) {
|
||||
await agent.send("Try again.");
|
||||
}
|
||||
@@ -548,8 +549,6 @@ Turn ends: "out of credits"
|
||||
→ a new turn starts
|
||||
```
|
||||
|
||||
该处理函数只获取最近 20 条时间线条目,不检查这一页之前的输出。插件需要更多历史时,请用 `cursor` 和 `direction` 继续翻页。
|
||||
|
||||
这会用现有的 SDK 发送一条新消息。持续匹配会不断发送后续消息;需要时请在插件中添加次数限制或延迟。附件和工具的效果不会被重放。
|
||||
|
||||
## agent.permission.before {#agentpermissionbefore}
|
||||
|
||||
@@ -207,6 +207,22 @@ setting `id` as an option key and each `value` as that option, and sends changes
|
||||
| `ProviderSetting` type | `ProviderOptionControl` |
|
||||
| `agent.create.before` `config.featureValues` | `config.providerOptions`; a returned `featureValues` edit is still applied |
|
||||
|
||||
### Turn history in v0.16
|
||||
|
||||
`agent.turn.after` has sent an empty `timeline` since v0.13, and the SDK type no longer declares it.
|
||||
A plugin that reads `event.timeline` now fails typecheck. Read the turn from the agent instead:
|
||||
|
||||
```ts
|
||||
const turn = await context.paseo.agents
|
||||
.ref(event.agent.id)
|
||||
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const items = turn.entries.filter((entry) => entry.turnId === event.turnId).map(({ item }) => item);
|
||||
```
|
||||
|
||||
The filter drops entries from a turn that started after this one ended, such as a follow-up another plugin sent. The list is empty when such a turn has already started, or when `event.turnId` is `null`.
|
||||
|
||||
Plugins built against an older SDK keep receiving `timeline: []` until removal after 2027-05-31.
|
||||
|
||||
## 5. Recognize half-migration errors
|
||||
|
||||
| Compiler or load error | Meaning and fix |
|
||||
|
||||
@@ -170,6 +170,21 @@ Provider 插件只有一个配置记录 `providerOptions`,并用控件描述
|
||||
| `ProviderSetting` 类型 | `ProviderOptionControl` |
|
||||
| `agent.create.before` 的 `config.featureValues` | `config.providerOptions`;返回的 `featureValues` 修改仍会被应用 |
|
||||
|
||||
### v0.16 中的轮次历史 {#turn-history-in-v016}
|
||||
|
||||
自 v0.13 起,`agent.turn.after` 发送的 `timeline` 始终为空,SDK 类型也不再声明它。读取 `event.timeline` 的插件现在无法通过类型检查。请改为从 Agent 读取本轮:
|
||||
|
||||
```ts
|
||||
const turn = await context.paseo.agents
|
||||
.ref(event.agent.id)
|
||||
.timeline.refetch({ direction: "tail", limit: 1, wholeTurns: true });
|
||||
const items = turn.entries.filter((entry) => entry.turnId === event.turnId).map(({ item }) => item);
|
||||
```
|
||||
|
||||
过滤会去掉本轮结束后才开始的新一轮的条目,例如另一个插件发送的后续消息。如果这样的新一轮已经开始,或 `event.turnId` 为 `null`,列表为空。
|
||||
|
||||
基于旧版 SDK 构建的插件在 2027-05-31 之后移除兼容层之前仍会收到 `timeline: []`。
|
||||
|
||||
## 5. 识别只迁移了一半的错误 {#5-recognize-half-migration-errors}
|
||||
|
||||
| 编译器或加载错误 | 含义与修复方法 |
|
||||
|
||||
@@ -118,7 +118,7 @@ workspace the call creates is background too. With a `parent` and no value, the
|
||||
|
||||
### Timeline handle
|
||||
|
||||
`agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, and `requestId`.
|
||||
`agent.timeline.refetch(options?)` fetches a page. Options are `direction`, `cursor`, `limit`, `projection`, `wholeTurns`, and `requestId`. `wholeTurns: true` extends a `tail` or `before` page back to the start of the oldest turn it reaches, so `{ direction: "tail", limit: 1, wholeTurns: true }` returns the latest turn. It requires `features.wholeTurnTimeline`; older hosts ignore it and return a page of `limit` items.
|
||||
|
||||
`agent.timeline.subscribe(handler)` establishes network demand for this agent and restores it after reconnect. Its unsubscribe function releases that demand; await `unsubscribe.ready` for initial daemon acknowledgement before starting work. Delivery is live-only. Reconnect emits a local `subscription_restored` event; request missed history explicitly with `refetch()`. A live replacement invalidates the previous epoch. See the callback shapes, paging and failure behavior in [timeline events](./events.md#follow-timeline-events).
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ nav: API 参考
|
||||
|
||||
### 时间线句柄 {#timeline-handle}
|
||||
|
||||
`agent.timeline.refetch(options?)` 获取一页时间线。选项有 `direction`、`cursor`、`limit`、`projection` 和 `requestId`。
|
||||
`agent.timeline.refetch(options?)` 获取一页时间线。选项有 `direction`、`cursor`、`limit`、`projection`、`wholeTurns` 和 `requestId`。`wholeTurns: true` 会把 `tail` 或 `before` 页向前扩展到它所触及的最早一轮的开头,因此 `{ direction: "tail", limit: 1, wholeTurns: true }` 返回最近一轮。它需要 `features.wholeTurnTimeline`;旧版主机会忽略它,返回 `limit` 条记录。
|
||||
|
||||
`agent.timeline.subscribe(handler)` 为该 Agent 建立网络订阅需求,并在重连后恢复它。它返回的取消订阅函数会释放这项需求;开始工作之前,请 await `unsubscribe.ready` 以等待 daemon 的初始确认。投递只包含实时内容。重连时会发出一个本地的 `subscription_restored` 事件;错过的历史请用 `refetch()` 显式请求。实时的替换会使之前的 epoch 失效。回调的形状、分页和失败行为见[时间线事件](./events.md#follow-timeline-events)。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user