Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 060d521340 test(codex): clean goal fixtures after setup failures 2026-10-13 11:03:17 +02:00
paseo-bot[bot]andBae Seongmin da5e8fbce3 fix(codex): record goal commands as user edits
Codex requires explicit user provenance before goal mutations enter model context. Mark every user /goal mutation accordingly and cover both emitted RPCs and native model input.

Co-authored-by: Bae Seongmin <83870598+3ae3ae@users.noreply.github.com>
2026-10-13 10:56:47 +02:00
14 changed files with 270 additions and 385 deletions
@@ -12,7 +12,6 @@ interface QueryMock {
close: ReturnType<typeof vi.fn>;
setPermissionMode: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
applyFlagSettings: ReturnType<typeof vi.fn>;
supportedModels: ReturnType<typeof vi.fn>;
supportedCommands: ReturnType<typeof vi.fn>;
rewindFiles: ReturnType<typeof vi.fn>;
@@ -149,7 +148,6 @@ function createScriptedQuery(params: {
close: vi.fn(() => undefined),
setPermissionMode: vi.fn(async () => undefined),
setModel: vi.fn(async () => undefined),
applyFlagSettings: vi.fn(async () => undefined),
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
supportedCommands: vi.fn(async () => []),
rewindFiles: vi.fn(async () => ({ canRewind: true })),
@@ -278,64 +276,6 @@ afterEach(() => {
queryFactory.mockReset();
});
async function createThinkingRestartSession() {
const queries: ScriptedQuery[] = [];
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const scriptedQuery = createScriptedQuery({
prompt,
sessionId: "thinking-restart-session",
handlePrompt: ({ query }) => query.emit(buildSuccessResult("thinking-restart-session")),
});
queries.push(scriptedQuery);
return scriptedQuery;
});
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
return { session, queries };
}
test("submits after a thinking change before the first turn", async () => {
const { session, queries } = await createThinkingRestartSession();
try {
await session.setThinkingOption!("medium");
const events = await collectUntilTerminal(
streamSession(session, "continue", { clientMessageId: "client-continue" }),
);
expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
expect(events.at(-1)?.type).toBe("turn_completed");
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queries[0].prompts.map((prompt) => prompt.text)).toEqual(["continue"]);
} finally {
await session.close();
}
});
test("submits after a thinking change once the previous query ended", async () => {
const { session, queries } = await createThinkingRestartSession();
try {
await collectUntilTerminal(streamSession(session, "first prompt"));
queries[0].end();
// Let the pump retire the old query before changing thinking.
await new Promise((resolve) => setTimeout(resolve, 0));
await session.setThinkingOption!("medium");
const events = await collectUntilTerminal(
streamSession(session, "continue", { clientMessageId: "client-continue" }),
);
expect(events.filter((event) => event.type === "turn_failed")).toEqual([]);
expect(events.at(-1)?.type).toBe("turn_completed");
expect(queryFactory).toHaveBeenCalledTimes(2);
expect(queries[1].prompts.map((prompt) => prompt.text)).toEqual(["continue"]);
} finally {
await session.close();
}
});
test("interrupt only calls query.interrupt and leaves the query open", async () => {
const logger = createTestLogger();
const queries: ScriptedQuery[] = [];
@@ -3275,9 +3275,6 @@ class ClaudeAgentSession implements AgentSession {
}
}
// A fresh query already uses the current settings, even when there was no old query to retire.
this.queryRestartNeeded = false;
// Preserve claudeSessionId across query recreation so buildOptions() passes
// resume: sessionId and the new query continues the existing conversation.
this.persistence = null;
@@ -3880,12 +3877,11 @@ class ClaudeAgentSession implements AgentSession {
}
private startQueryPump(): void {
if (this.closed || this.queryPumpPromise || !this.query) {
if (this.closed || this.queryPumpPromise) {
return;
}
// Drain the initialized query; a pending settings change belongs to the next turn.
const pump = this.runQueryPump(this.query).catch((error) => {
const pump = this.runQueryPump().catch((error) => {
this.logger.trace(
{
agentId: this.agentId,
@@ -3906,7 +3902,25 @@ class ClaudeAgentSession implements AgentSession {
});
}
private async runQueryPump(activeQuery: Query): Promise<void> {
private async runQueryPump(): Promise<void> {
let activeQuery: Query;
try {
activeQuery = await this.ensureQuery();
} catch (error) {
this.logger.trace(
{
agentId: this.agentId,
provider: "claude",
sessionId: this.claudeSessionId,
turnId: this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? undefined,
err: error,
},
"provider.claude.query_pump.init_failed",
);
this.failActiveTurns(error instanceof Error ? error.message : "Claude stream failed");
return;
}
let consecutiveInterruptAbortRecoveries = 0;
const logRawMessage = (message: SDKMessage): void => {
this.logger.trace(
@@ -5487,6 +5487,7 @@ describe("Codex app-server provider", () => {
method: "thread/goal/set",
params: {
threadId: "test-thread",
origin: "user",
objective: "ship feature",
status: "active",
},
@@ -5182,6 +5182,7 @@ export class CodexAppServerAgentSession implements AgentSession {
case "set": {
await this.client.request("thread/goal/set", {
threadId: this.currentThreadId,
origin: "user",
objective: subcommand.objective,
status: "active",
});
@@ -5190,6 +5191,7 @@ export class CodexAppServerAgentSession implements AgentSession {
case "pause": {
await this.client.request("thread/goal/set", {
threadId: this.currentThreadId,
origin: "user",
status: "paused",
});
return "Goal paused.";
@@ -5197,6 +5199,7 @@ export class CodexAppServerAgentSession implements AgentSession {
case "resume": {
await this.client.request("thread/goal/set", {
threadId: this.currentThreadId,
origin: "user",
status: "active",
});
return "Goal resumed.";
@@ -5204,6 +5207,7 @@ export class CodexAppServerAgentSession implements AgentSession {
case "clear": {
await this.client.request("thread/goal/clear", {
threadId: this.currentThreadId,
origin: "user",
});
return "Goal cleared.";
}
@@ -0,0 +1,144 @@
import { once } from "node:events";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import path from "node:path";
import { expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import type { AgentSession, AgentStreamEvent } from "../agent-sdk-types.js";
// Native Codex owns goals; the fixture supplies inference responses only.
test("records all four /goal commands as user edits in the next model request", async () => {
const root = mkdtempSync(path.resolve(".dev-goal-provenance-"));
const requests: string[] = [];
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
if (req.method !== "POST" || req.url !== "/v1/responses") {
res.writeHead(404).end();
return;
}
requests.push(Buffer.concat(chunks).toString("utf8"));
res.setHeader("content-type", "text/event-stream");
const id = `resp-${requests.length}`;
const events = [
{ type: "response.created", response: { id } },
{
type: "response.output_item.done",
item: {
type: "message",
role: "assistant",
id: `msg-${requests.length}`,
content: [{ type: "output_text", text: "LOCAL_FIXTURE_RESPONSE" }],
},
},
{
type: "response.completed",
response: {
id,
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
},
},
];
res.end(events.map((e) => `event: ${e.type}\ndata: ${JSON.stringify(e)}\n\n`).join(""));
});
}
const server = createServer(handleRequest);
let session: AgentSession | null = null;
try {
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
if (!address || typeof address === "string") throw new Error("missing fixture address");
writeFileSync(
path.join(root, "config.toml"),
`
model = "gpt-6.1-sol"
model_reasoning_effort = "low"
model_provider = "fixture"
approval_policy = "never"
sandbox_mode = "read-only"
[features]
enable_request_compression = false
[model_providers.fixture]
name = "Local response fixture"
base_url = "http://127.0.0.1:${address.port}/v1"
wire_api = "responses"
requires_openai_auth = false
supports_websockets = false
request_max_retries = 0
stream_max_retries = 0
`,
);
const client = new CodexAppServerAgentClient(createTestLogger(), { env: { CODEX_HOME: root } });
session = await client.createSession({
provider: "codex",
cwd: root,
model: "gpt-6.1-sol",
thinkingOptionId: "low",
});
let active = false;
const unsubscribe = session.subscribe((event) => {
if (event.type === "turn_started") active = true;
if (["turn_completed", "turn_canceled", "turn_failed"].includes(event.type)) active = false;
});
try {
expect((await session.listCommands?.())?.some((command) => command.name === "goal")).toBe(
true,
);
for (const command of [
"/goal AUDIT_GOAL: summarize local evidence",
"/goal pause",
"/goal resume",
"/goal clear",
]) {
const handler = session.tryHandleOutOfBand?.(command);
expect(handler).toBeTruthy();
const events: AgentStreamEvent[] = [];
await handler!.run({ emit: (event) => events.push(event) });
expect(events).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
item: expect.objectContaining({
text: expect.stringContaining("Failed to update goal"),
}),
}),
]),
);
}
await expect.poll(() => active, { timeout: 30_000 }).toBe(false);
expect((await session.run("PROBE_AFTER_GOAL_CLEAR")).finalText).toBe(
"LOCAL_FIXTURE_RESPONSE",
);
const body = JSON.parse(requests.at(-1)!);
const goalContext = body.input
.filter((item: { role: string }) => item.role === "user")
.flatMap((item: { content: Array<{ text?: string }> }) =>
item.content.map((part) => part.text ?? ""),
)
.filter((text: string) => text.includes('<codex_internal_context source="user_goal">'))
.join("\n");
expect(goalContext).toContain('User set the goal: "AUDIT_GOAL: summarize local evidence"');
expect(goalContext).toContain('User set goal status: "paused".');
expect(goalContext).toContain('User set goal status: "active".');
expect(goalContext).toContain("User cleared the goal.");
} finally {
unsubscribe();
}
} finally {
try {
await session?.close();
} finally {
try {
server.closeAllConnections();
if (server.listening) {
server.close();
await once(server, "close");
}
} finally {
rmSync(root, { recursive: true, force: true });
}
}
}
}, 60_000);
@@ -0,0 +1,84 @@
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import path from "node:path";
import { expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import type { AgentSession } from "../agent-sdk-types.js";
// A protocol peer exercises the public session interface without a Codex install.
test("marks set, pause, resume and clear as user goal mutations", async () => {
const root = mkdtempSync(path.resolve(".dev-goal-protocol-"));
const script = path.join(root, "codex.cjs");
const capture = path.join(root, "requests.jsonl");
writeFileSync(
script,
`
const fs = require("node:fs");
const readline = require("node:readline");
if (process.argv.includes("--version")) {
console.log("codex-cli 0.162.1");
process.exit(0);
}
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
if (request.id === undefined) return;
fs.appendFileSync(process.env.CAPTURE, JSON.stringify(request) + "\\n");
let result = {};
if (request.method === "initialize") result = { userAgent: "codex-cli/0.162.1" };
if (request.method === "thread/start") result = { thread: { id: "goal-thread" } };
if (request.method === "thread/loaded/list") result = { data: ["goal-thread"] };
process.stdout.write(JSON.stringify({ id: request.id, result }) + "\\n");
});
`,
);
const client = new CodexAppServerAgentClient(createTestLogger(), {
command: { mode: "replace", argv: [process.execPath, script] },
env: { CODEX_HOME: root, CAPTURE: capture },
});
let session: AgentSession | null = null;
try {
session = await client.createSession({
provider: "codex",
cwd: root,
model: "test-model",
thinkingOptionId: "low",
});
for (const command of ["/goal ship feature", "/goal pause", "/goal resume", "/goal clear"]) {
const handler = session.tryHandleOutOfBand?.(command);
if (!handler) throw new Error(`Missing handler for ${command}`);
await handler.run({ emit: () => {} });
}
const requests = readFileSync(capture, "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line));
const mutations = requests.filter((request) => request.method.startsWith("thread/goal/"));
expect(mutations.map(({ method, params }) => ({ method, params }))).toEqual([
{
method: "thread/goal/set",
params: {
threadId: "goal-thread",
origin: "user",
objective: "ship feature",
status: "active",
},
},
{
method: "thread/goal/set",
params: { threadId: "goal-thread", origin: "user", status: "paused" },
},
{
method: "thread/goal/set",
params: { threadId: "goal-thread", origin: "user", status: "active" },
},
{ method: "thread/goal/clear", params: { threadId: "goal-thread", origin: "user" } },
]);
} finally {
try {
await session?.close();
} finally {
rmSync(root, { recursive: true, force: true });
}
}
});
@@ -115,43 +115,4 @@ describe("daemon E2E (real claude) - thinking effort memory", () => {
rmSync(cwd, { recursive: true, force: true });
}
}, 420_000);
test("a thinking change before the first turn does not fail that turn", async () => {
const logger = pino({ level: "silent" });
const cwd = tmpCwd();
const daemon = await createTestPaseoDaemon({
agentClients: createRealProviderClients(["claude"], logger),
logger,
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const modelList = await client.listProviderModels("claude", { cwd });
const model = modelList.models.find(modelHasLowThinkingOption);
if (!model) {
throw new Error("No Claude Sonnet model with low thinking effort returned");
}
const agent = await client.createAgent({
cwd,
title: "claude-thinking-before-first-turn-real",
...getRealProviderConfig("claude"),
model: model.id,
});
await client.setAgentThinkingOption(agent.id, "low");
await client.sendMessage(agent.id, "Reply exactly: ACK_FIRST");
const finish = await client.waitForFinish(agent.id, 180_000);
expect(finish.final?.lastError).toBeUndefined();
expect(finish.status).toBe("idle");
expect(compactText(await getAssistantText(client, agent.id))).toContain("ack_first");
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
}, 300_000);
});
@@ -1,16 +1,7 @@
import { describe, test, expect, beforeEach, afterEach, onTestFinished } from "vitest";
import {
mkdtempSync,
writeFileSync,
rmSync,
mkdirSync,
symlinkSync,
readdirSync,
readlinkSync,
} from "fs";
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, mkdirSync, symlinkSync, readdirSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { execFileSync } from "node:child_process";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
function tmpCwd(): string {
@@ -396,84 +387,6 @@ describe("file explorer", () => {
}
});
test.skipIf(process.platform === "win32")(
"duplicating a folder keeps relative symlinks inside the copy",
async () => {
const cwd = tmpCwd();
try {
mkdirSync(path.join(cwd, "template"));
writeFileSync(path.join(cwd, "template", "data.txt"), "original payload\n");
symlinkSync("data.txt", path.join(cwd, "template", "alias.txt"));
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "template" });
expect(duplicated).toMatchObject({
duplicatedPath: "template copy",
success: true,
error: null,
});
expect(readlinkSync(path.join(cwd, "template copy", "alias.txt"))).toBe("data.txt");
const copiedTarget = await ctx.client.readFile(cwd, "template copy/data.txt");
const targetWrite = await ctx.client.writeFile({
cwd,
path: "template copy/data.txt",
content: "copy payload\n",
expectedModifiedAt: copiedTarget.modifiedAt,
expectedRevision: copiedTarget.revision,
});
expect(targetWrite.status).toBe("written");
const copiedAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
expect(new TextDecoder().decode(copiedAlias.bytes)).toBe("copy payload\n");
const aliasWrite = await ctx.client.writeFile({
cwd,
path: "template copy/alias.txt",
content: "edited through copied alias\n",
expectedModifiedAt: copiedAlias.modifiedAt,
expectedRevision: copiedAlias.revision,
});
expect(aliasWrite.status).toBe("written");
const original = await ctx.client.readFile(cwd, "template/data.txt");
expect(new TextDecoder().decode(original.bytes)).toBe("original payload\n");
const editedCopy = await ctx.client.readFile(cwd, "template copy/data.txt");
expect(new TextDecoder().decode(editedCopy.bytes)).toBe("edited through copied alias\n");
const deleted = await ctx.client.deleteFileEntry({ cwd, path: "template" });
expect(deleted).toMatchObject({ success: true, error: null });
const survivingAlias = await ctx.client.readFile(cwd, "template copy/alias.txt");
expect(new TextDecoder().decode(survivingAlias.bytes)).toBe(
"edited through copied alias\n",
);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
test.skipIf(process.platform === "win32")(
"duplicating a file symlink preserves an explicit absolute target",
async () => {
const cwd = tmpCwd();
const targetPath = path.join(cwd, "data.txt");
try {
writeFileSync(targetPath, "absolute target payload\n");
symlinkSync(targetPath, path.join(cwd, "alias.txt"));
const duplicated = await ctx.client.duplicateFileEntry({ cwd, path: "alias.txt" });
expect(duplicated).toMatchObject({
duplicatedPath: "alias copy.txt",
success: true,
error: null,
});
expect(readlinkSync(path.join(cwd, "alias copy.txt"))).toBe(targetPath);
const copy = await ctx.client.readFile(cwd, "alias copy.txt");
expect(new TextDecoder().decode(copy.bytes)).toBe("absolute target payload\n");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
);
test("lists directory contents", async () => {
const cwd = tmpCwd();
@@ -576,63 +489,3 @@ describe("file explorer", () => {
rmSync(cwd, { recursive: true, force: true });
}, 60000); // 1 minute timeout
});
describe("commit file diffs", () => {
test.each([".", "apps/web"])("opens committed files from %s workspaces", async (relativePath) => {
const cwd = tmpCwd();
onTestFinished(() =>
rmSync(cwd, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
);
const workspace = path.join(cwd, relativePath);
const filePath = "apps/web/notes.txt";
function git(args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
git(["init", "-b", "main"]);
git(["config", "user.name", "Test"]);
git(["config", "user.email", "test@example.com"]);
git(["config", "core.autocrlf", "false"]);
mkdirSync(path.join(cwd, "apps", "web"), { recursive: true });
writeFileSync(path.join(cwd, filePath), "before\n");
writeFileSync(path.join(cwd, "apps/web/untouched.txt"), "unchanged\n");
git(["add", "."]);
git(["-c", "commit.gpgsign=false", "commit", "-m", "initial"]);
git(["checkout", "-b", "feature"]);
writeFileSync(path.join(cwd, filePath), "after\n");
git(["add", "."]);
git(["-c", "commit.gpgsign=false", "commit", "-m", "edit"]);
const sha = git(["rev-parse", "HEAD"]).trim();
const history = await ctx.client.listCheckoutCommits(workspace);
const commit = history.commits.find((entry) => entry.sha === sha);
expect(commit?.files).toEqual([
{ path: filePath, additions: 1, deletions: 1, status: "modified" },
]);
const result = await ctx.client.getCommitFileDiff(workspace, sha, filePath);
expect(result).toEqual({
file: {
path: filePath,
isNew: false,
isDeleted: false,
additions: 1,
deletions: 1,
hunks: [
{
oldStart: 1,
oldCount: 1,
newStart: 1,
newCount: 1,
lines: [
{ type: "header", content: "@@ -1 +1 @@" },
{ type: "remove", content: "before" },
{ type: "add", content: "after" },
],
},
],
},
});
expect(await ctx.client.getCommitFileDiff(workspace, sha, "apps/web/untouched.txt")).toEqual({
file: null,
});
});
});
@@ -1,6 +1,6 @@
import { execFile as execFileCallback } from "node:child_process";
import { mkdtempSync, realpathSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import { readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
@@ -63,12 +63,10 @@ test("an empty project becomes Git without changing its identity or creating a w
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: {} });
const projectEvents = client.observeEvents(["project.update"]);
await projectEvents.ready;
const added = await client.addProject(projectRoot);
expect(added).toMatchObject({
expect(added).toEqual({
requestId: expect.any(String),
project: {
projectId: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
@@ -79,11 +77,7 @@ test("an empty project becomes Git without changing its identity or creating a w
},
error: null,
});
const {
projectIconRevision: _icon,
projectCustomIconRevision: _customIcon,
...project
} = added.project!;
const project = added.project!;
const beforeGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(beforeGitInit).toMatchObject({ entries: [], emptyProjects: [project] });
@@ -102,15 +96,15 @@ test("an empty project becomes Git without changing its identity or creating a w
label: "project.update after git init",
});
expect(update).toMatchObject({
expect(update).toEqual({
kind: "upsert",
project: { ...project, projectKind: "git", projectKey: expect.any(String) },
project: { ...project, projectKind: "git" },
});
const afterGitInit = await client.fetchWorkspaces({ filter: { projectId: project.projectId } });
expect(afterGitInit).toMatchObject({
entries: [],
emptyProjects: [{ ...project, projectKind: "git", projectKey: expect.any(String) }],
emptyProjects: [{ ...project, projectKind: "git" }],
});
const persistedProjects = JSON.parse(
@@ -125,72 +119,5 @@ test("an empty project becomes Git without changing its identity or creating a w
createdAt: expect.any(String),
updatedAt: expect.any(String),
archivedAt: null,
projectKey: expect.any(String),
customIconRevision: null,
});
}, 30_000);
test("a failed worktree attempt before git init does not prevent creation afterward", async () => {
const projectRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-worktree-")));
const paseoHomeRoot = realpathSync(mkdtempSync(path.join(os.tmpdir(), "paseo-init-home-")));
cleanupPaths.add(projectRoot);
cleanupPaths.add(paseoHomeRoot);
await writeFile(path.join(projectRoot, "README.md"), "hi\n");
const daemon = await createTestPaseoDaemon({ paseoHomeRoot, cleanup: false });
cleanupDaemons.add(daemon);
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
cleanupClients.add(client);
await client.connect();
await client.fetchAgents({ subscribe: {} });
const projectEvents = client.observeEvents(["project.update"]);
await projectEvents.ready;
const added = await client.addProject(projectRoot);
expect(added.project?.projectKind).toBe("non_git");
const source = {
kind: "worktree" as const,
cwd: projectRoot,
projectId: added.project!.projectId,
action: "branch-off" as const,
refName: "main",
branchName: "after-init",
worktreeSlug: "after-init",
};
const before = await client.createWorkspace({ source });
expect(before.error).toBe("Create worktree requires a git repository");
const gitProjectUpdate = waitForProjectUpdate(
client,
(payload) =>
payload.kind === "upsert" &&
payload.project.projectId === added.project!.projectId &&
payload.project.projectKind === "git",
);
await execFile("git", ["init", "-b", "main"], { cwd: projectRoot });
await execFile("git", ["add", "README.md"], { cwd: projectRoot });
await execFile(
"git",
[
"-c",
"user.name=Paseo Test",
"-c",
"user.email=test@getpaseo.dev",
"-c",
"commit.gpgSign=false",
"commit",
"-m",
"initial",
],
{ cwd: projectRoot },
);
await withTimeout({ promise: gitProjectUpdate, timeoutMs: 10_000, label: "project becomes Git" });
const projects = await client.fetchWorkspaces({
filter: { projectId: added.project!.projectId },
});
expect(projects.emptyProjects[0]?.projectKind).toBe("git");
const created = await client.createWorkspace({ source });
expect(created.error).toBeNull();
expect(created.workspace?.workspaceKind).toBe("worktree");
expect(created.workspace?.projectId).toBe(added.project!.projectId);
}, 30_000);
@@ -669,7 +669,6 @@ export async function duplicateExplorerEntry({
errorOnExist: true,
force: false,
preserveTimestamps: true,
verbatimSymlinks: true,
});
return { status: "ok", path: normalizeRelativePath({ root, targetPath }) };
} catch (error) {
@@ -236,8 +236,7 @@ test("observes a thousand concurrent writes and remains healthy after delete and
const deletions = expectFileChanges(removedPaths, "delete");
const movedFiles = expectFileChanges(movedPaths);
const recovery = expectFileChanges([sentinel]);
const observer = createObserver();
const subscription = await observer.subscribe(root, (error, events) => {
const subscription = await subscribeToFileChanges(root, (error, events) => {
expect(error).toBeNull();
for (const event of events) {
population.record(event);
@@ -255,18 +254,9 @@ test("observes a thousand concurrent writes and remains healthy after delete and
await Promise.all(paths.map((path, index) => writeFile(path, `${index}`)));
await writes.complete;
// The first notice for each write can arrive before its remaining
// classifications. Drain those deliveries before starting a separate deletion
// phase, so a late create cannot coalesce with the expected delete as update.
await expect
.poll(() => observer.getDiagnostics(), { timeout: 10_000 })
.toMatchObject({
pendingEventCount: 0,
pendingClassificationCount: 0,
reconciliationInFlightCount: 0,
});
// Completion still requires the delivered deletion notices.
// A native watcher may recover coalesced paths through its safety audit.
// Completion comes from the delivered deletions, not an idle diagnostic or
// a deadline shorter than that recovery cycle.
await Promise.all(removedPaths.map((path) => rm(path)));
await deletions.complete;
@@ -63,27 +63,6 @@ afterEach(async () => {
}
});
test("a fresh checkout read makes a newly initialized repository available to cached readers", async () => {
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-checkout-init-")));
const repo = path.join(root, "repo");
mkdirSync(repo);
const service = new WorkspaceGitServiceImpl({
logger: pino({ enabled: false }),
paseoHome: path.join(root, "home"),
});
cleanup.push(async () => {
await service.dispose();
rmSync(root, { recursive: true, force: true });
});
expect((await service.getSnapshot(repo, { includeForge: false })).git.isGit).toBe(false);
execFileSync("git", ["init", "-b", "main"], { cwd: repo, stdio: "pipe" });
expect((await service.getCheckout(repo)).isGit).toBe(true);
const repoRoot = await service.resolveRepoRoot(repo);
expect(realpathSync.native(repoRoot)).toBe(realpathSync.native(repo));
}, 30_000);
test("recursive observation updates tracked state and prunes ignored storms", async () => {
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-git-observation-")));
const repoDir = path.join(tempDir, "repo");
@@ -765,16 +765,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
worktreesRoot: this.worktreesRoot,
logger: this.logger,
});
const cached = this.peekSnapshot(normalizedCwd);
if (cached && cached.git.isGit !== status.isGit) {
// A fresh checkout read discovered a repository transition. Keep the
// shared snapshot current before exposing the new project capability.
await this.getSnapshot(normalizedCwd, {
force: true,
includeForge: false,
reason: "checkout-classification-changed",
});
}
if (!status.isGit) {
return checkoutLiteFromGitSnapshot(normalizedCwd, {
isGit: false,
+4 -5
View File
@@ -2665,11 +2665,10 @@ export async function getCommitFileDiff({
sha: string;
path: string;
}): Promise<ParsedDiffFile | null> {
const worktreeRoot = await requireGitWorktreeRoot(cwd);
const { stdout } = await runGitCommand(
["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path],
{
cwd: worktreeRoot,
cwd,
envOverlay: READ_ONLY_GIT_ENV,
},
);
@@ -2678,9 +2677,9 @@ export async function getCommitFileDiff({
return null;
}
const parsedFiles = await parseAndHighlightDiff(stdout, worktreeRoot, {
getOldFileContent: (file) => readGitFileContentAtRef(worktreeRoot, `${sha}^`, file.path),
getNewFileContent: (file) => readGitFileContentAtRef(worktreeRoot, sha, file.path),
const parsedFiles = await parseAndHighlightDiff(stdout, cwd, {
getOldFileContent: (file) => readGitFileContentAtRef(cwd, `${sha}^`, file.path),
getNewFileContent: (file) => readGitFileContentAtRef(cwd, sha, file.path),
});
// `--` scopes the diff to a single pathspec, so there is at most one real