Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-05-19 18:12:20 +07:00
92 changed files with 6574 additions and 3521 deletions
@@ -1750,6 +1750,119 @@ test("requests checkout pull via RPC", async () => {
});
});
test("renames a branch via RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.renameBranch({
cwd: "/tmp/project",
branch: "feature/new-name",
requestId: "req-rename-branch",
});
expect(mock.sent).toHaveLength(1);
const request = JSON.parse(mock.sent[0]) as {
type: "session";
message: {
type: "checkout.rename_branch.request";
cwd: string;
branch: string;
requestId: string;
};
};
expect(request.message.type).toBe("checkout.rename_branch.request");
expect(request.message.cwd).toBe("/tmp/project");
expect(request.message.branch).toBe("feature/new-name");
expect(request.message.requestId).toBe("req-rename-branch");
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout.rename_branch.response",
payload: {
requestId: "req-rename-branch",
success: true,
cwd: "/tmp/project",
currentBranch: "feature/new-name",
error: null,
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: "req-rename-branch",
success: true,
cwd: "/tmp/project",
currentBranch: "feature/new-name",
error: null,
});
});
test("returns renameBranch business failures", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.renameBranch({
cwd: "/tmp/project",
branch: "already-exists",
requestId: "req-rename-branch-fail",
});
expect(mock.sent).toHaveLength(1);
mock.triggerMessage(
JSON.stringify({
type: "session",
message: {
type: "checkout.rename_branch.response",
payload: {
requestId: "req-rename-branch-fail",
success: false,
cwd: "/tmp/project",
currentBranch: null,
error: { code: "NOT_ALLOWED", message: "Branch already exists" },
},
},
}),
);
await expect(promise).resolves.toEqual({
requestId: "req-rename-branch-fail",
success: false,
cwd: "/tmp/project",
currentBranch: null,
error: { code: "NOT_ALLOWED", message: "Branch already exists" },
});
});
test("resubscribes checkout diff streams after reconnect", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -5,7 +5,9 @@ import {
AgentCreatedStatusPayloadSchema,
AgentRefreshedStatusPayloadSchema,
AgentResumedStatusPayloadSchema,
CheckoutRenameBranchResponseSchema,
parseServerInfoStatusPayload,
RenameTerminalResponseSchema,
RestartRequestedStatusPayloadSchema,
ShutdownRequestedStatusPayloadSchema,
SessionInboundMessageSchema,
@@ -60,6 +62,8 @@ import type {
GetProvidersSnapshotResponseMessage,
RefreshProvidersSnapshotResponseMessage,
ProviderDiagnosticResponseMessage,
DaemonGetStatusResponse,
DaemonGetPairingOfferResponse,
ListTerminalsResponse,
CreateTerminalResponse,
SubscribeTerminalResponse,
@@ -285,6 +289,7 @@ type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["pay
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"];
type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"];
export type RenameBranchResult = z.infer<typeof CheckoutRenameBranchResponseSchema>["payload"];
type StashSavePayload = StashSaveResponse["payload"];
type StashPopPayload = StashPopResponse["payload"];
type StashListPayload = StashListResponse["payload"];
@@ -317,6 +322,8 @@ type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
type DaemonStatusPayload = DaemonGetStatusResponse["payload"];
type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"];
type ReadProjectConfigPayload = Extract<
SessionOutboundMessage,
{ type: "read_project_config_response" }
@@ -351,6 +358,7 @@ type DictationFinishAcceptedPayload = Extract<
type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"];
type ListTerminalsPayload = ListTerminalsResponse["payload"];
type CreateTerminalPayload = CreateTerminalResponse["payload"];
export type RenameTerminalResult = z.infer<typeof RenameTerminalResponseSchema>["payload"];
type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type CloseItemsPayload = CloseItemsResponse["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"];
@@ -614,6 +622,16 @@ export interface UpdateScheduleOptions {
expiresAt?: string | null;
requestId?: string;
}
export interface RenameBranchInput {
cwd: string;
branch: string;
requestId?: string;
}
export interface RenameTerminalInput {
terminalId: string;
title: string;
requestId?: string;
}
type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"];
type OpenInEditorPayload = OpenInEditorResponseMessage["payload"];
type OpenProjectPayload = OpenProjectResponseMessage["payload"];
@@ -2929,6 +2947,19 @@ export class DaemonClient {
});
}
async renameBranch(input: RenameBranchInput): Promise<RenameBranchResult> {
return this.sendCorrelatedSessionRequest({
requestId: input.requestId,
message: {
type: "checkout.rename_branch.request",
cwd: input.cwd,
branch: input.branch,
},
responseType: "checkout.rename_branch.response",
timeout: 30000,
});
}
async stashSave(
cwd: string,
options?: { branch?: string },
@@ -3298,6 +3329,28 @@ export class DaemonClient {
});
}
async getDaemonStatus(requestId?: string): Promise<DaemonStatusPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "daemon.get_status.request",
},
responseType: "daemon.get_status.response",
timeout: 10000,
});
}
async getDaemonPairingOffer(requestId?: string): Promise<DaemonPairingOfferPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "daemon.get_pairing_offer.request",
},
responseType: "daemon.get_pairing_offer.response",
timeout: 10000,
});
}
async patchDaemonConfig(
config: MutableDaemonConfigPatch,
requestId?: string,
@@ -3635,6 +3688,19 @@ export class DaemonClient {
});
}
async renameTerminal(input: RenameTerminalInput): Promise<RenameTerminalResult> {
return this.sendCorrelatedSessionRequest({
requestId: input.requestId,
message: {
type: "terminal.rename.request",
terminalId: input.terminalId,
title: input.title,
},
responseType: "terminal.rename.response",
timeout: 10000,
});
}
async subscribeTerminal(
terminalId: string,
requestId?: string,
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentManager } from "./agent-manager.js";
import { AgentManager, type ManagedAgent } from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js";
import type { StoredAgentRecord } from "./agent-storage.js";
@@ -1457,6 +1457,111 @@ test("setTitle bumps updatedAt and persists title in the same snapshot write", a
expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt));
});
test("setGeneratedTitleIfUnset preserves an existing user title", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-preserve-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000128",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
await manager.setTitle(snapshot.id, "User title");
await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title");
const after = await storage.get(snapshot.id);
expect(after?.title).toBe("User title");
});
test("setGeneratedTitleIfUnset persists generated title when no title exists", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-empty-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000129",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title");
const after = await storage.get(snapshot.id);
expect(after?.title).toBe("Generated title");
});
test("setGeneratedTitleIfUnset ignores blank generated titles", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-blank-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000130",
});
const snapshot = await manager.createAgent({
provider: "codex",
cwd: workdir,
});
const before = await storage.get(snapshot.id);
expect(before).not.toBeNull();
const stateEvents: ManagedAgent[] = [];
manager.subscribe(
(event) => {
if (event.type === "agent_state") {
stateEvents.push(event.agent);
}
},
{ agentId: snapshot.id, replayState: false },
);
await manager.setGeneratedTitleIfUnset(snapshot.id, " ");
const after = await storage.get(snapshot.id);
expect(after?.title).toBeNull();
expect(after?.updatedAt).toBe(before?.updatedAt);
expect(manager.getAgent(snapshot.id)?.updatedAt.toISOString()).toBe(before?.updatedAt);
expect(stateEvents).toEqual([]);
});
test("setGeneratedTitleIfUnset throws for an unknown agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-unknown-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
});
await expect(
manager.setGeneratedTitleIfUnset("00000000-0000-4000-8000-000000000999", "Generated title"),
).rejects.toThrow("Unknown agent '00000000-0000-4000-8000-000000000999'");
});
test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-"));
const storagePath = join(workdir, "agents");
@@ -1228,6 +1228,23 @@ export class AgentManager {
this.emitState(agent, { persist: false });
}
async setGeneratedTitleIfUnset(agentId: string, title: string): Promise<void> {
const agent = this.requireAgent(agentId);
const normalizedTitle = title.trim();
if (!normalizedTitle) {
return;
}
const registry = this.requireRegistry();
const persisted = await registry.setGeneratedTitleIfUnset(agent.id, normalizedTitle);
if (!persisted) {
return;
}
agent.updatedAt = new Date(persisted.updatedAt);
this.emitState(agent, { persist: false });
}
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
const agent = this.requireAgent(agentId);
agent.labels = { ...agent.labels, ...labels };
@@ -158,7 +158,7 @@ export async function generateAndApplyAgentMetadata(
if (needs.needsTitle && typeof result.title === "string") {
const normalizedTitle = normalizeAutoTitle(result.title);
if (normalizedTitle) {
await options.agentManager.setTitle(options.agentId, normalizedTitle);
await options.agentManager.setGeneratedTitleIfUnset(options.agentId, normalizedTitle);
}
}
}
@@ -39,8 +39,8 @@ function createDeps(
describe("agent metadata generator auto-title", () => {
it("caps generated auto titles at 40 characters before persisting", async () => {
const setTitle = vi.fn().mockResolvedValue(undefined);
const manager = { setTitle } as unknown as AgentManager;
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
const generatedTitle = "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS + 25);
const generateStructured = vi.fn().mockResolvedValue({ title: generatedTitle }) as NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
@@ -56,13 +56,16 @@ describe("agent metadata generator auto-title", () => {
deps: createDeps(generateStructured),
});
expect(setTitle).toHaveBeenCalledTimes(1);
expect(setTitle).toHaveBeenCalledWith("agent-1", "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS));
expect(setGeneratedTitleIfUnset).toHaveBeenCalledTimes(1);
expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith(
"agent-1",
"x".repeat(MAX_AUTO_AGENT_TITLE_CHARS),
);
});
it("does not generate an auto title when an explicit title is provided", async () => {
const setTitle = vi.fn().mockResolvedValue(undefined);
const manager = { setTitle } as unknown as AgentManager;
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated" }) as NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
>;
@@ -78,12 +81,12 @@ describe("agent metadata generator auto-title", () => {
});
expect(generateStructured).not.toHaveBeenCalled();
expect(setTitle).not.toHaveBeenCalled();
expect(setGeneratedTitleIfUnset).not.toHaveBeenCalled();
});
it("generates titles independently from workspace branch naming", async () => {
const setTitle = vi.fn().mockResolvedValue(undefined);
const manager = { setTitle } as unknown as AgentManager;
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
const generateStructured = vi
.fn()
.mockResolvedValue({ title: "Generated title" }) as NonNullable<
@@ -108,7 +111,10 @@ describe("agent metadata generator auto-title", () => {
persistSession: false,
}),
);
expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title");
expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith(
"agent-suppressed-branch",
"Generated title",
);
});
it.each([
@@ -170,8 +176,8 @@ async function generateTitlePromptWithConfig(config: unknown): Promise<{ prompt:
writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`);
}
const setTitle = vi.fn().mockResolvedValue(undefined);
const manager = { setTitle } as unknown as AgentManager;
const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined);
const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager;
const generateStructured = vi.fn().mockResolvedValue({ title: "Generated title" }) as NonNullable<
AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"]
>;
@@ -321,6 +321,42 @@ describe("AgentStorage", () => {
);
});
test("setGeneratedTitleIfUnset aborts when a user title is already set", async () => {
const agentId = "agent-generated-title-race";
await storage.applySnapshot(createManagedAgent({ id: agentId }));
await storage.setTitle(agentId, "User title");
const result = await storage.setGeneratedTitleIfUnset(agentId, "Generated title");
expect(result).toBeNull();
const record = await storage.get(agentId);
expect(record?.title).toBe("User title");
});
test("setGeneratedTitleIfUnset with concurrent writes does not corrupt state", async () => {
const agentId = "agent-generated-title-concurrent";
await storage.applySnapshot(createManagedAgent({ id: agentId }));
await Promise.all([
storage.setGeneratedTitleIfUnset(agentId, "Title A"),
storage.setGeneratedTitleIfUnset(agentId, "Title B"),
]);
const record = await storage.get(agentId);
expect(["Title A", "Title B"]).toContain(record?.title);
});
test("setGeneratedTitleIfUnset writes the generated title only when title is empty", async () => {
const agentId = "agent-generated-title-empty";
await storage.applySnapshot(createManagedAgent({ id: agentId }));
const written = await storage.setGeneratedTitleIfUnset(agentId, "Generated title");
expect(written?.title).toBe("Generated title");
const record = await storage.get(agentId);
expect(record?.title).toBe("Generated title");
});
test("applySnapshot accepts explicit title overrides", async () => {
const agentId = "agent-override";
await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" });
@@ -115,44 +115,51 @@ export class AgentStorage {
async upsert(record: StoredAgentRecord): Promise<void> {
await this.load();
await this.queueRecordWrite(record);
}
private queueRecordWrite(record: StoredAgentRecord): Promise<void> {
const agentId = record.id;
const prev = this.pendingWrites.get(agentId) ?? Promise.resolve();
const next = prev.then(async () => {
if (this.deleting.has(agentId)) {
return;
return undefined;
}
const nextPath = this.buildRecordPath(record);
const previousPath = this.pathById.get(agentId);
await fs.mkdir(path.dirname(nextPath), { recursive: true });
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
this.addIndexedPath(agentId, nextPath);
if (previousPath && previousPath !== nextPath) {
try {
await fs.unlink(previousPath);
} catch {
// ignore cleanup errors
}
this.removeIndexedPath(agentId, previousPath);
}
this.cache.set(agentId, record);
this.pathById.set(agentId, nextPath);
return;
await this.writeRecord(record);
return undefined;
});
this.pendingWrites.set(
agentId,
next.finally(() => {
if (this.pendingWrites.get(agentId) === next) {
this.pendingWrites.delete(agentId);
}
}),
);
const tracked = next.finally(() => {
if (this.pendingWrites.get(agentId) === tracked) {
this.pendingWrites.delete(agentId);
}
});
await next;
this.pendingWrites.set(agentId, tracked);
return tracked;
}
private async writeRecord(record: StoredAgentRecord): Promise<void> {
const agentId = record.id;
const nextPath = this.buildRecordPath(record);
const previousPath = this.pathById.get(agentId);
await fs.mkdir(path.dirname(nextPath), { recursive: true });
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
this.addIndexedPath(agentId, nextPath);
if (previousPath && previousPath !== nextPath) {
try {
await fs.unlink(previousPath);
} catch {
// ignore cleanup errors
}
this.removeIndexedPath(agentId, previousPath);
}
this.cache.set(agentId, record);
this.pathById.set(agentId, nextPath);
}
beginDelete(agentId: string): void {
@@ -225,6 +232,40 @@ export class AgentStorage {
await this.upsert({ ...record, title });
}
async setGeneratedTitleIfUnset(
agentId: string,
title: string,
): Promise<StoredAgentRecord | null> {
await this.load();
await this.waitForPendingWrite(agentId);
const record = this.cache.get(agentId) ?? null;
if (!record) {
throw new Error(`Agent ${agentId} not found`);
}
if (record.title) {
return null;
}
// Re-drain pending writes: a concurrent setTitle may have queued between the
// first drain and here. After waiting, re-read the cache before writing.
await this.waitForPendingWrite(agentId);
const latestRecord = this.cache.get(agentId) ?? null;
if (!latestRecord) {
throw new Error(`Agent ${agentId} not found`);
}
if (latestRecord.title) {
return null;
}
const nextRecord = {
...latestRecord,
title,
updatedAt: new Date().toISOString(),
};
await this.queueRecordWrite(nextRecord);
return nextRecord;
}
async flush(): Promise<void> {
await this.load().catch(() => undefined);
const writes = Array.from(this.pendingWrites.values());
@@ -154,7 +154,7 @@ async function createTopLevelAgent(args?: Partial<StructuredContent>): Promise<s
title: "Parity agent",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
mode: "bypassPermissions",
settings: { modeId: "bypassPermissions" },
background: true,
...args,
});
@@ -240,7 +240,7 @@ beforeAll(async () => {
title: "MCP parity parent",
provider: "claude/claude-test-model",
initialPrompt: "say done and stop",
mode: "bypassPermissions",
settings: { modeId: "bypassPermissions" },
background: true,
});
parentAgentId = str(parentPayload.agentId);
@@ -339,7 +339,7 @@ describe("Suite A: Core Fixes", () => {
test("create_agent accepts provider features over MCP", async () => {
let agentId: string | null = null;
try {
agentId = await createTopLevelAgent({ features: { test_feature: true } });
agentId = await createTopLevelAgent({ settings: { features: { test_feature: true } } });
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
@@ -356,7 +356,7 @@ describe("Suite A: Core Fixes", () => {
try {
agentId = await createChildAgent({
provider: "claude/claude-test-model",
features: { test_feature: true },
settings: { features: { test_feature: true } },
});
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
@@ -369,14 +369,13 @@ describe("Suite A: Core Fixes", () => {
}
});
test("set_agent_feature updates provider features over MCP", async () => {
test("update_agent updates provider features over MCP", async () => {
let agentId: string | null = null;
try {
agentId = await createTopLevelAgent({ features: { test_feature: false } });
const updated = await callToolStructured(topLevelClient, "set_agent_feature", {
agentId = await createTopLevelAgent({ settings: { features: { test_feature: false } } });
const updated = await callToolStructured(topLevelClient, "update_agent", {
agentId,
featureId: "test_feature",
value: true,
settings: { features: { test_feature: true } },
});
expect(updated.success).toBe(true);
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
@@ -390,15 +389,18 @@ describe("Suite A: Core Fixes", () => {
}
});
test("list_provider_features returns draft provider features over MCP", async () => {
const payload = await callToolStructured(topLevelClient, "list_provider_features", {
test("inspect_provider returns draft provider features over MCP", async () => {
const payload = await callToolStructured(topLevelClient, "inspect_provider", {
provider: "claude",
cwd: parentAgentCwd,
model: "claude-test-model",
featureValues: { test_feature: true },
settings: {
model: "claude-test-model",
features: { test_feature: true },
},
});
expect(payload.provider).toBe("claude");
expect(payload.selectedModel).toBe("claude-test-model");
expect(recordArr(payload.features)).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -117,7 +117,9 @@ function buildAgentManagerSpies() {
createAgent: vi.fn(),
waitForAgentEvent: vi.fn(),
recordUserMessage: vi.fn(),
setAgentMode: vi.fn(),
setAgentMode: vi.fn().mockResolvedValue(undefined),
setAgentModel: vi.fn().mockResolvedValue(undefined),
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
setAgentFeature: vi.fn().mockResolvedValue(undefined),
setLabels: vi.fn().mockResolvedValue(undefined),
setTitle: vi.fn().mockResolvedValue(undefined),
@@ -446,7 +448,7 @@ describe("create_agent MCP tool", () => {
const missingTitle = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
mode: "default",
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
initialPrompt: "test",
});
@@ -455,7 +457,7 @@ describe("create_agent MCP tool", () => {
const tooLong = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
mode: "default",
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
title: "x".repeat(61),
initialPrompt: "test",
@@ -465,7 +467,7 @@ describe("create_agent MCP tool", () => {
const ok = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
mode: "default",
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
title: "Short title",
initialPrompt: "test",
@@ -479,7 +481,7 @@ describe("create_agent MCP tool", () => {
const tool = registeredTool(server, "create_agent");
const parsed = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
mode: "default",
settings: { modeId: "default" },
provider: "codex/gpt-5.4",
title: "Short title",
});
@@ -510,7 +512,7 @@ describe("create_agent MCP tool", () => {
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
features: { fast_mode: true },
settings: { features: { fast_mode: true } },
};
const parsed = await tool.inputSchema.safeParseAsync(input);
@@ -536,7 +538,7 @@ describe("create_agent MCP tool", () => {
const missingProvider = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
mode: "default",
settings: { modeId: "default" },
title: "Short title",
initialPrompt: "test",
});
@@ -549,7 +551,7 @@ describe("create_agent MCP tool", () => {
const providerWithoutModel = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
mode: "default",
settings: { modeId: "default" },
title: "Short title",
provider: "codex",
initialPrompt: "test",
@@ -558,7 +560,7 @@ describe("create_agent MCP tool", () => {
const providerWithEmptyModel = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
mode: "default",
settings: { modeId: "default" },
title: "Short title",
provider: "codex/",
initialPrompt: "test",
@@ -567,7 +569,7 @@ describe("create_agent MCP tool", () => {
const providerWithEmptyProvider = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
mode: "default",
settings: { modeId: "default" },
title: "Short title",
provider: "/gpt-5.4",
initialPrompt: "test",
@@ -577,7 +579,7 @@ describe("create_agent MCP tool", () => {
await expect(
tool.handler({
cwd: existingCwd,
mode: "default",
settings: { modeId: "default" },
title: "Short title",
provider: "codex/gpt-5.4",
model: "gpt-5.4",
@@ -722,10 +724,9 @@ describe("create_agent MCP tool", () => {
await tool.handler({
cwd: existingCwd,
title: "Config test",
mode: "auto",
initialPrompt: "Do work",
provider: "codex/gpt-5.4",
thinking: "think-hard",
settings: { modeId: "auto", thinkingOptionId: "think-hard" },
labels: { source: "mcp" },
});
@@ -1269,7 +1270,7 @@ describe("create_agent MCP tool", () => {
const parsed = await tool.inputSchema.safeParseAsync({
cwd: existingCwd,
title: "Custom provider agent",
mode: "default",
settings: { modeId: "default" },
provider: "zai/custom-model",
initialPrompt: "Do work",
});
@@ -1360,7 +1361,7 @@ describe("create_agent MCP tool", () => {
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
background: true,
features: { fast_mode: true },
settings: { features: { fast_mode: true } },
};
const parsed = await tool.inputSchema.safeParseAsync(input);
@@ -1403,7 +1404,7 @@ describe("create_agent MCP tool", () => {
await tool.handler({
cwd: existingCwd,
title: "Injected config test",
mode: "auto",
settings: { modeId: "auto" },
provider: "codex/gpt-5.4",
initialPrompt: "Do work",
});
@@ -1428,7 +1429,7 @@ describe("create_agent MCP tool", () => {
cwd: existingCwd,
title: "Bad mode",
provider: "opencode/gpt-5.4",
mode: "bypassPermissions",
settings: { modeId: "bypassPermissions" },
initialPrompt: "Do work",
}),
).rejects.toThrow(
@@ -1567,7 +1568,7 @@ describe("create_agent MCP tool", () => {
await tool.handler({
title: "Child",
provider: "opencode/gpt-5.4",
mode: "build",
settings: { modeId: "build" },
initialPrompt: "Do work",
});
@@ -1579,17 +1580,31 @@ describe("create_agent MCP tool", () => {
});
});
describe("set_agent_feature MCP tool", () => {
describe("update_agent MCP tool", () => {
const logger = createTestLogger();
it("sets a provider feature on an existing agent", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
it("does not register the replaced feature-specific MCP tool", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "set_agent_feature");
expect(lookupTool(server, "set_agent_feature")).toBeUndefined();
});
it("updates runtime settings before metadata", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentStorage.get.mockResolvedValue(createStoredRecord({ id: "agent-1" }));
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "update_agent");
const input = {
agentId: "agent-1",
featureId: "fast_mode",
value: true,
name: "Updated agent",
labels: { role: "worker" },
settings: {
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
features: { fast_mode: true },
},
};
const parsed = await tool.inputSchema.safeParseAsync(input);
@@ -1597,9 +1612,39 @@ describe("set_agent_feature MCP tool", () => {
const response = await tool.handler(input);
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
expect(spies.agentStorage.upsert).toHaveBeenCalledWith(
expect.objectContaining({
id: "agent-1",
title: "Updated agent",
}),
);
expect(spies.agentManager.setLabels).toHaveBeenCalledWith("agent-1", { role: "worker" });
expect(response.structuredContent).toEqual({ success: true });
});
it("does not update metadata when runtime settings fail", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
const tool = registeredTool(server, "update_agent");
await expect(
tool.handler({
agentId: "agent-1",
name: "Should not persist",
labels: { role: "worker" },
settings: { features: { fast_mode: true } },
}),
).rejects.toThrow("unsupported feature");
expect(spies.agentStorage.get).not.toHaveBeenCalled();
expect(spies.agentStorage.upsert).not.toHaveBeenCalled();
expect(spies.agentManager.setLabels).not.toHaveBeenCalled();
});
});
describe("create_schedule MCP tool", () => {
@@ -2033,10 +2078,17 @@ describe("provider listing MCP tool", () => {
});
});
describe("model listing MCP tool", () => {
describe("provider MCP tools", () => {
const logger = createTestLogger();
it("lists provider features for a draft agent configuration", async () => {
it("does not register the replaced feature-specific provider discovery MCP tool", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
expect(lookupTool(server, "list_provider_features")).toBeUndefined();
});
it("inspects provider features for a draft agent configuration", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.listDraftFeatures.mockResolvedValue([
{
@@ -2046,19 +2098,29 @@ describe("model listing MCP tool", () => {
value: false,
},
]);
const providerRegistry = {
codex: createProviderDefinition({
id: "codex",
label: "Codex",
description: "OpenAI coding agent",
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
}),
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerRegistry,
logger,
});
const tool = registeredTool(server, "list_provider_features");
const tool = registeredTool(server, "inspect_provider");
const input = {
provider: "codex",
provider: "codex/gpt-5.4",
cwd: "~/repo",
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
settings: {
modeId: "full-access",
thinkingOptionId: "high",
features: { fast_mode: true },
},
};
const parsed = await tool.inputSchema.safeParseAsync(input);
@@ -2076,6 +2138,12 @@ describe("model listing MCP tool", () => {
});
expect(response.structuredContent).toEqual({
provider: "codex",
label: "Codex",
description: "OpenAI coding agent",
enabled: true,
status: "available",
modes: [{ id: "full-access", label: "Full Access", description: "Can edit files" }],
selectedModel: "gpt-5.4",
features: [
{
type: "toggle",
@@ -2118,6 +2186,38 @@ describe("model listing MCP tool", () => {
);
expect(fetchModels).not.toHaveBeenCalled();
});
it("inspect_provider rejects disabled providers without fetching models", async () => {
const { agentManager, agentStorage } = createTestDeps();
const fetchModels = vi.fn().mockResolvedValue([
{
provider: "codex",
id: "gpt-5.4",
label: "GPT-5.4",
},
]);
const providerRegistry = {
codex: createProviderDefinition({
id: "codex",
label: "Codex",
enabled: false,
fetchModels,
}),
};
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerRegistry,
logger,
});
const tool = registeredTool(server, "inspect_provider");
await expect(tool.handler({ provider: "codex", cwd: "~/repo" })).rejects.toThrow(
"Provider 'codex' is disabled",
);
expect(fetchModels).not.toHaveBeenCalled();
});
});
describe("speak MCP tool", () => {
+144 -74
View File
@@ -62,6 +62,7 @@ import {
AgentModelSchema,
AgentProviderEnum,
AgentStatusEnum,
ProviderModeSchema,
ProviderSummarySchema,
parseDurationString,
resolveRequiredProviderModel,
@@ -587,6 +588,55 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
{ message: "provider must be provider/model, for example codex/gpt-5.4" },
);
const ProviderOrProviderModelInputSchema = AgentProviderEnum.trim()
.min(1, "provider is required")
.refine(
(value) => {
if (!value.includes("/")) {
return true;
}
try {
resolveRequiredProviderModel(value);
return true;
} catch {
return false;
}
},
{ message: "provider must be provider or provider/model, for example codex/gpt-5.4" },
);
const CreateAgentSettingsInputSchema = z
.object({
modeId: z.string().optional().describe("Session mode to configure before the first run."),
thinkingOptionId: z.string().optional().describe("Thinking option ID."),
features: z
.record(z.unknown())
.optional()
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
})
.strict();
const UpdateAgentSettingsInputSchema = z
.object({
modeId: z.string().optional().describe("Session mode ID."),
model: z.string().nullable().optional().describe("Model ID. Pass null to clear."),
thinkingOptionId: z
.string()
.nullable()
.optional()
.describe("Thinking option ID. Pass null to clear."),
features: z
.record(z.unknown())
.optional()
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
})
.strict();
const InspectProviderSettingsInputSchema = z
.object({
modeId: z.string().optional().describe("Draft session mode ID."),
model: z.string().optional().describe("Draft model ID."),
thinkingOptionId: z.string().optional().describe("Draft thinking option ID."),
features: z.record(z.unknown()).optional().describe("Draft provider feature values."),
})
.strict();
const agentToAgentInputSchema = {
cwd: z
.string()
@@ -601,23 +651,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
provider: ProviderModelInputSchema.describe(
"Required provider/model pair, for example codex/gpt-5.4.",
),
thinking: z.string().optional().describe("Thinking option ID"),
features: z
.record(z.unknown())
.optional()
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
settings: CreateAgentSettingsInputSchema.optional().describe(
"Initial runtime settings for the new agent.",
),
initialPrompt: z
.string()
.trim()
.min(1, "initialPrompt is required")
.describe("Required first task to run immediately after creation."),
mode: z
.string()
.optional()
.describe(
"Optional session mode for the new agent. Required when the new agent uses a different provider than the caller agent.",
),
background: z
.boolean()
.optional()
@@ -647,21 +689,15 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
provider: ProviderModelInputSchema.describe(
"Required provider/model pair, for example codex/gpt-5.4.",
),
thinking: z.string().optional().describe("Thinking option ID"),
features: z
.record(z.unknown())
.optional()
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
settings: CreateAgentSettingsInputSchema.optional().describe(
"Initial runtime settings for the new agent.",
),
initialPrompt: z
.string()
.trim()
.min(1, "initialPrompt is required")
.describe("Required first task to run immediately after creation."),
mode: z
.string()
.optional()
.describe("Optional session mode to configure before the first run."),
worktreeName: z
.string()
.optional()
@@ -700,13 +736,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
const createAgentInputSchema = callerAgentId ? agentToAgentInputSchema : topLevelInputSchema;
const agentToAgentCreateAgentArgsSchema = z.object(agentToAgentInputSchema).strict();
const topLevelCreateAgentArgsSchema = z.object(topLevelInputSchema).strict();
const listProviderFeaturesInputSchema = {
provider: AgentProviderEnum,
cwd: z.string().describe("Working directory used to resolve provider feature availability."),
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
featureValues: z.record(z.unknown()).optional(),
const inspectProviderInputSchema = {
provider: ProviderOrProviderModelInputSchema.describe(
"Provider ID, optionally with a model ID (for example codex or codex/gpt-5.4).",
),
cwd: z
.string()
.optional()
.describe("Working directory used to resolve provider feature availability."),
settings: InspectProviderSettingsInputSchema.optional().describe(
"Draft provider settings used to compute available features.",
),
};
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
@@ -758,7 +798,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
background: boolean;
normalizedTitle: string | null;
model: string | undefined;
thinking: string | undefined;
thinkingOptionId: string | undefined;
features: Record<string, unknown> | undefined;
labels: Record<string, string> | undefined;
notifyOnFinish: boolean;
@@ -805,6 +845,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
throw new Error(`Parent agent ${parentAgentId} not found`);
}
const provider = resolvedProviderModel.provider;
const settings = callerArgs.settings;
const resolvedCwd = resolveChildAgentCwd({
parentCwd: parentAgent.cwd,
requestedCwd: callerArgs.cwd,
@@ -812,7 +853,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
allowCustomCwd: callerContext?.allowCustomCwd ?? true,
});
const resolvedMode = resolveAndValidateCreateAgentMode({
requestedMode: callerArgs.mode,
requestedMode: settings?.modeId,
targetProvider: provider,
parent: {
provider: parentAgent.provider,
@@ -828,8 +869,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
background: callerArgs.background ?? false,
normalizedTitle: callerArgs.title.trim(),
model: resolvedProviderModel.model,
thinking: callerArgs.thinking,
features: callerArgs.features,
thinkingOptionId: settings?.thinkingOptionId,
features: settings?.features,
labels: callerArgs.labels,
notifyOnFinish: callerArgs.notifyOnFinish ?? false,
resolvedCwd,
@@ -843,9 +884,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
): Promise<ResolvedCreateAgentArgs> => {
const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args);
const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider);
const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs;
const { cwd, settings, worktreeName, baseBranch, refName, action, githubPrNumber } =
topLevelArgs;
const resolvedMode = resolveAndValidateCreateAgentMode({
requestedMode: mode,
requestedMode: settings?.modeId,
targetProvider: resolvedProviderModel.provider,
parent: null,
availableModes: getAvailableModeIds(resolvedProviderModel.provider),
@@ -902,8 +944,8 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
background: topLevelArgs.background ?? false,
normalizedTitle: topLevelArgs.title.trim(),
model: resolvedProviderModel.model,
thinking: topLevelArgs.thinking,
features: topLevelArgs.features,
thinkingOptionId: settings?.thinkingOptionId,
features: settings?.features,
labels: topLevelArgs.labels,
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
resolvedCwd,
@@ -946,7 +988,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
background,
normalizedTitle,
model,
thinking,
thinkingOptionId,
features,
labels,
notifyOnFinish,
@@ -968,7 +1010,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
modeId: resolvedMode,
title: normalizedTitle ?? undefined,
model,
thinkingOptionId: thinking,
thinkingOptionId,
featureValues: features,
},
undefined,
@@ -1057,29 +1099,6 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
},
);
server.registerTool(
"set_agent_feature",
{
title: "Set agent feature",
description: "Set a provider-specific feature on an existing agent, such as Codex fast_mode.",
inputSchema: {
agentId: z.string(),
featureId: z.string().trim().min(1),
value: z.unknown(),
},
outputSchema: {
success: z.boolean(),
},
},
async ({ agentId, featureId, value }) => {
await agentManager.setAgentFeature(agentId, featureId, value);
return {
content: [],
structuredContent: ensureValidJson({ success: true }),
};
},
);
server.registerTool(
"wait_for_agent",
{
@@ -1440,17 +1459,35 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
"update_agent",
{
title: "Update agent",
description: "Update an agent name and/or labels.",
description: "Update an agent name, labels, and/or runtime settings.",
inputSchema: {
agentId: z.string(),
name: z.string().optional(),
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
settings: UpdateAgentSettingsInputSchema.optional().describe(
"Runtime settings to apply to the agent.",
),
},
outputSchema: {
success: z.boolean(),
},
},
async ({ agentId, name, labels }) => {
async ({ agentId, name, labels, settings }) => {
if (settings?.modeId !== undefined) {
await agentManager.setAgentMode(agentId, settings.modeId);
}
if (settings?.model !== undefined) {
await agentManager.setAgentModel(agentId, settings.model);
}
if (settings?.thinkingOptionId !== undefined) {
await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId);
}
if (settings?.features) {
for (const [featureId, value] of Object.entries(settings.features)) {
await agentManager.setAgentFeature(agentId, featureId, value);
}
}
const trimmedName = name?.trim();
if (trimmedName) {
const record = await agentStorage.get(agentId);
@@ -2022,30 +2059,63 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
);
server.registerTool(
"list_provider_features",
"inspect_provider",
{
title: "List provider features",
title: "Inspect provider",
description:
"List provider-specific features available for a draft agent configuration, such as Codex fast_mode.",
inputSchema: listProviderFeaturesInputSchema,
"Inspect compact provider capabilities for orchestration, including modes and draft feature settings. Use list_models for the full model list.",
inputSchema: inspectProviderInputSchema,
outputSchema: {
provider: AgentProviderEnum,
label: z.string().nullable().optional(),
description: z.string().nullable().optional(),
enabled: z.boolean(),
status: z.string(),
modes: z.array(ProviderModeSchema).nullish(),
selectedModel: z.string().nullable(),
features: z.array(AgentFeatureSchema),
},
},
async ({ provider, cwd, modeId, model, thinkingOptionId, featureValues }) => {
const features = await agentManager.listDraftFeatures({
async ({ provider, cwd, settings }) => {
const resolvedProviderModel = resolveScheduleProviderAndModel({
provider,
cwd: expandUserPath(cwd),
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
...(featureValues ? { featureValues } : {}),
defaultProvider: provider,
});
const providerId = resolvedProviderModel.provider;
if (!providerRegistry) {
throw new Error("Provider registry is not configured");
}
const definition = providerRegistry[providerId];
if (!definition) {
throw new Error(`Provider ${providerId} is not configured`);
}
const summary = await resolveProviderSummary(definition, childLogger);
if (!definition.enabled) {
throw new Error(`Provider '${providerId}' is disabled`);
}
if (summary.status !== "available") {
throw new Error(summary.error ?? `Provider '${providerId}' is unavailable`);
}
const resolvedCwd = resolveScopedCwd(cwd, { required: true });
const selectedModel = settings?.model ?? resolvedProviderModel.model;
const features = await agentManager.listDraftFeatures({
provider: providerId,
cwd: resolvedCwd,
...(settings?.modeId ? { modeId: settings.modeId } : {}),
...(selectedModel ? { model: selectedModel } : {}),
...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
...(settings?.features ? { featureValues: settings.features } : {}),
});
return {
content: [],
structuredContent: ensureValidJson({
provider,
provider: providerId,
label: summary.label,
description: summary.description,
enabled: summary.enabled,
status: summary.status,
modes: summary.modes,
selectedModel: selectedModel ?? null,
features,
}),
};
@@ -1438,6 +1438,26 @@ describe("ACPAgentSession slash commands", () => {
});
describe("ACPAgentSession", () => {
test("accepts ACP extension notifications without failing the JSON-RPC connection", async () => {
const logger = createTestLogger();
const trace = vi.spyOn(logger, "trace");
const session = createSessionWithConfig({ provider: "kiro" }, logger);
await expect(
session.extNotification("_kiro.dev/session/initialized", {
sessionId: "session-1",
}),
).resolves.toBeUndefined();
expect(trace).toHaveBeenCalledWith(
expect.objectContaining({
provider: "kiro",
method: "_kiro.dev/session/initialized",
sessionId: "session-1",
}),
"provider.acp.extension_notification",
);
});
test("emits assistant and reasoning chunks as deltas while user chunks stay accumulated", async () => {
const session = createSession();
const events: Array<{ type: string; item?: { type: string; text?: string } }> = [];
@@ -1667,6 +1667,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
}
async extNotification(method: string, params: Record<string, unknown>): Promise<void> {
this.logger.trace(
{
agentId: this.agentId,
provider: this.provider,
sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined,
method,
rawEvent: params,
},
"provider.acp.extension_notification",
);
}
async readTextFile(params: ReadTextFileRequest): Promise<{ content: string }> {
const raw = await fs.readFile(params.path, "utf8");
if (!params.line && !params.limit) {
@@ -1461,4 +1461,64 @@ describe("ClaudeAgentSession context window usage", () => {
expect(timelineEvents).toEqual([]);
expect(events.some((event) => event.type === "turn_completed")).toBe(true);
});
test("result.result is not duplicated when assistant text already streamed with zero token usage", async () => {
const queryFactory = createQueryFactoryForTurns([
[
{
type: "system",
subtype: "init",
session_id: "session-third-party",
permissionMode: "default",
},
{
type: "assistant",
message: {
id: "assistant-third-party-1",
role: "assistant",
content: [{ type: "text", text: "Here is the answer." }],
usage: {
input_tokens: 0,
output_tokens: 0,
},
},
session_id: "session-third-party",
uuid: "assistant-third-party-event-1",
},
{
type: "result",
subtype: "success",
result: "Here is the answer.",
is_error: false,
duration_ms: 100,
duration_api_ms: 80,
num_turns: 1,
stop_reason: null,
total_cost_usd: 0.01,
usage: {
input_tokens: 10,
cache_read_input_tokens: 0,
output_tokens: 0,
},
permission_denials: [],
uuid: "result-third-party-1",
session_id: "session-third-party",
},
],
]);
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
});
const result = await session.run("turn");
await session.close();
expect(result.timeline).toEqual([{ type: "assistant_message", text: "Here is the answer." }]);
});
});
@@ -1568,6 +1568,7 @@ class ClaudeAgentSession implements AgentSession {
private pendingInterruptAbort = false;
private lastForegroundPromptText: string | null = null;
private foregroundHasVisibleActivity = false;
private activeTurnHasAssistantText = false;
private lastContextWindowUsedTokens: number | undefined;
private lastContextWindowMaxTokens: number | undefined;
private lastStreamRequestInputTokens: number | undefined;
@@ -1692,6 +1693,7 @@ class ClaudeAgentSession implements AgentSession {
const turnId = this.createTurnId("foreground");
this.activeForegroundTurnId = turnId;
this.foregroundHasVisibleActivity = false;
this.activeTurnHasAssistantText = false;
this.transitionTurnState("foreground", "foreground turn started");
this.clearRecentStderr();
@@ -2630,6 +2632,7 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.cancelCurrentTurn = null;
this.activeTurnHasAssistantText = false;
this.syncTurnState("foreground turn terminal");
}
@@ -2645,9 +2648,11 @@ class ClaudeAgentSession implements AgentSession {
this.activeForegroundTurnId = null;
this.lastForegroundPromptText = null;
this.cancelCurrentTurn = null;
this.activeTurnHasAssistantText = false;
this.syncTurnState("foreground turn terminal");
} else if (this.autonomousTurn) {
this.autonomousTurn = null;
this.activeTurnHasAssistantText = false;
this.syncTurnState("autonomous turn terminal");
}
}
@@ -2660,6 +2665,7 @@ class ClaudeAgentSession implements AgentSession {
this.autonomousTurn = {
id: this.createTurnId("autonomous"),
};
this.activeTurnHasAssistantText = false;
this.notifySubscribers({ type: "turn_started", provider: "claude" });
this.syncTurnState("autonomous turn started");
}
@@ -2670,6 +2676,7 @@ class ClaudeAgentSession implements AgentSession {
}
this.notifySubscribers({ type: "turn_completed", provider: "claude" });
this.autonomousTurn = null;
this.activeTurnHasAssistantText = false;
this.syncTurnState("autonomous turn completed");
}
@@ -2898,7 +2905,6 @@ class ClaudeAgentSession implements AgentSession {
if (events.length === 0) {
return;
}
if (
this.pendingInterruptAbort &&
message.type === "result" &&
@@ -2909,6 +2915,11 @@ class ClaudeAgentSession implements AgentSession {
this.logger.debug("Suppressing stale Claude interrupt terminal result");
return;
}
if (
events.some((event) => event.type === "timeline" && event.item.type === "assistant_message")
) {
this.activeTurnHasAssistantText = true;
}
if (
this.activeForegroundTurnId &&
events.some(
@@ -3231,12 +3242,12 @@ class ClaudeAgentSession implements AgentSession {
if (message.subtype === "success") {
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
// run client-side in the Claude CLI with no model turn — output_tokens
// is 0 and the user-visible text is carried in `result`. Surface it as
// an assistant message so the turn doesn't end silently. Normal turns
// have output_tokens > 0 and their text is already in the stream.
// is 0 and the user-visible text is carried in `result`. Surface it only
// when the turn has not already emitted assistant text so zero-token
// accounting from provider gateways does not duplicate streamed output.
const resultText = typeof message.result === "string" ? message.result.trim() : "";
const outputTokens = message.usage?.output_tokens;
if (resultText.length > 0 && outputTokens === 0) {
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
events.push({
type: "timeline",
provider: "claude",
@@ -1352,6 +1352,40 @@ describe("Codex app-server provider", () => {
});
});
test("does not synthesize a parent sub-agent failure from child error state alone", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "call-sub-agent-transient-child-error",
tool: "spawnAgent",
status: "completed",
prompt: "Validate the child agent result.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "error", message: "Sub-agent failed" },
},
},
});
expect(events.at(-1)?.item).toMatchObject({
type: "tool_call",
callId: "call-sub-agent-transient-child-error",
name: "Sub-agent",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Validate the child agent result.",
},
});
});
test("loads Codex persisted history from the app-server thread", async () => {
const session = createSession();
const requests: Array<{ method: string; params: unknown }> = [];
@@ -52,7 +52,7 @@ import {
resolveProviderCommandPrefix,
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
import { findExecutable, isCommandAvailable, probeExecutable } from "../../../utils/executable.js";
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
@@ -375,8 +375,61 @@ function mergeCodexConfiguredDefaults(
};
}
function codexMicrosoftStorePackageRoot(): string | null {
const localAppData = process.env.LOCALAPPDATA;
if (!localAppData) {
return null;
}
return path.join(localAppData, "Packages");
}
async function findCodexMicrosoftStoreBinary(): Promise<string | null> {
if (process.platform !== "win32") {
return null;
}
const packageRoot = codexMicrosoftStorePackageRoot();
if (!packageRoot) {
return null;
}
let entries: Dirent[];
try {
entries = await fs.readdir(packageRoot, { withFileTypes: true });
} catch {
return null;
}
const codexPackages = entries
.filter((entry) => entry.isDirectory() && entry.name.startsWith("OpenAI.Codex_"))
.map((entry) => entry.name)
.sort();
for (const packageName of codexPackages) {
const candidate = path.join(
packageRoot,
packageName,
"LocalCache",
"Local",
"OpenAI",
"Codex",
"bin",
"codex.exe",
);
if (await probeExecutable(candidate)) {
return candidate;
}
}
return null;
}
async function findDefaultCodexBinary(): Promise<string | null> {
return (await findExecutable("codex")) ?? (await findCodexMicrosoftStoreBinary());
}
async function resolveCodexBinary(): Promise<string> {
const found = await findExecutable("codex");
const found = await findDefaultCodexBinary();
if (found) {
return found;
}
@@ -5299,13 +5352,13 @@ export class CodexAppServerAgentClient implements AgentClient {
if (command?.mode === "replace") {
return await isCommandAvailable(command.argv[0]);
}
return await isCommandAvailable("codex");
return (await findDefaultCodexBinary()) !== null;
}
async getDiagnostic(): Promise<{ diagnostic: string }> {
try {
const available = await this.isAvailable();
const resolvedBinary = await findExecutable("codex");
const resolvedBinary = await findDefaultCodexBinary();
const entries: Array<{ label: string; value: string }> = [
{
label: "Binary",
@@ -5448,6 +5501,8 @@ export const __codexAppServerInternals = {
CodexAppServerClient,
codexModelSupportsFastMode,
CodexAppServerAgentSession,
findCodexMicrosoftStoreBinary,
findDefaultCodexBinary,
formatCodexQuestionPrompts,
mapCodexQuestionRequestToToolCall,
mapCodexPatchNotificationToToolCall,
@@ -212,6 +212,64 @@ describe("codex tool-call mapper", () => {
});
});
it("does not fail a collabAgentToolCall from child error state alone", () => {
const item = mapCodexToolCallFromThreadItem({
type: "collabAgentToolCall",
id: "call-sub-agent-transient-child-error",
tool: "spawnAgent",
status: "completed",
prompt: "Inspect the Codex stream path.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "error", message: "Sub-agent failed" },
},
});
expect(item).toEqual({
type: "tool_call",
callId: "call-sub-agent-transient-child-error",
name: "Sub-agent",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Inspect the Codex stream path.",
log: "",
actions: [],
},
});
});
it("still fails a collabAgentToolCall from an explicitly failed child state", () => {
const item = mapCodexToolCallFromThreadItem({
type: "collabAgentToolCall",
id: "call-sub-agent-child-failed",
tool: "spawnAgent",
status: "completed",
prompt: "Inspect the Codex stream path.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "failed", message: "Child failed" },
},
});
expect(item).toEqual({
type: "tool_call",
callId: "call-sub-agent-child-failed",
name: "Sub-agent",
status: "failed",
error: { message: "Sub-agent failed" },
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Inspect the Codex stream path.",
log: "",
actions: [],
},
});
});
it("maps mcp read_file completion with detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
@@ -544,6 +544,14 @@ function readStatus(value: unknown): string | undefined {
return typeof value.status === "string" ? value.status : undefined;
}
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
const normalized = status.trim().toLowerCase();
if (normalized === "error" || normalized === "errored") {
return "running";
}
return normalizeToolCallStatus(status, null, null);
}
function resolveCollabAgentStatus(
item: z.infer<typeof CodexCollabAgentToolCallItemSchema>,
): ToolCallTimelineItem["status"] {
@@ -551,10 +559,15 @@ function resolveCollabAgentStatus(
return "failed";
}
const parentStatus = normalizeToolCallStatus(item.status, null, null);
if (parentStatus === "failed") {
return "failed";
}
const childStatuses = Object.values(item.agentsStates ?? {})
.map(readStatus)
.filter((status): status is string => typeof status === "string" && status.trim().length > 0)
.map((status) => normalizeToolCallStatus(status, null, null));
.map(normalizeCollabAgentChildStatus);
if (childStatuses.some((status) => status === "failed")) {
return "failed";
@@ -566,7 +579,7 @@ function resolveCollabAgentStatus(
return childStatuses.every((status) => status === "completed") ? "completed" : "running";
}
return normalizeToolCallStatus(item.status, item.error ?? null, null);
return parentStatus;
}
function buildMcpToolName(server: string | undefined, tool: string): string {
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, test, vi } from "vitest";
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
import pino from "pino";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
@@ -25,9 +25,9 @@ import {
type ResolvedCommand,
type Skill,
type WriteToolInput,
} from "@mariozechner/pi-coding-agent";
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Api, ImageContent, Model, TextContent } from "@mariozechner/pi-ai";
} from "@earendil-works/pi-coding-agent";
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai";
import { z } from "zod";
import {
@@ -30,7 +30,7 @@ export interface PiSessionRecoveryResult {
}
// COMPAT(piCopilot413): added 2026-05-13 for Pi <= 0.73.1; target removal
// 2026-11-13, once upstream @mariozechner/pi-ai recognizes this overflow.
// 2026-11-13, once upstream @earendil-works/pi-ai recognizes this overflow.
const PI_COPILOT_SHORT_413_OVERFLOW_PATTERN = /^413\s+failed to parse request$/i;
const PI_SESSION_RECOVERY_POLICIES: readonly PiSessionRecoveryPolicy[] = [
@@ -1,4 +1,4 @@
import { mkdtempSync, rmSync } from "node:fs";
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
@@ -9,10 +9,11 @@ import { AgentManager } from "../agent-manager.js";
import { AgentStorage } from "../agent-storage.js";
import { ClaudeAgentClient } from "./claude/agent.js";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { __codexAppServerInternals, CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
const originalEnv = {
LOCALAPPDATA: process.env.LOCALAPPDATA,
PATH: process.env.PATH,
PATHEXT: process.env.PATHEXT,
};
@@ -31,7 +32,19 @@ function isolatePathTo(dir: string): void {
}
}
function isolateCodexDefaultDiscoveryTo(dir: string): void {
isolatePathTo(dir);
if (process.platform === "win32") {
process.env.LOCALAPPDATA = dir;
}
}
afterEach(() => {
if (originalEnv.LOCALAPPDATA === undefined) {
delete process.env.LOCALAPPDATA;
} else {
process.env.LOCALAPPDATA = originalEnv.LOCALAPPDATA;
}
process.env.PATH = originalEnv.PATH;
process.env.PATHEXT = originalEnv.PATHEXT;
for (const dir of tempDirs.splice(0)) {
@@ -42,12 +55,51 @@ afterEach(() => {
describe("default provider availability", () => {
test("Codex reports unavailable when the default command cannot be resolved", async () => {
const binDir = makeTempDir("provider-availability-codex-");
isolatePathTo(binDir);
isolateCodexDefaultDiscoveryTo(binDir);
const client = new CodexAppServerAgentClient(createTestLogger());
await expect(client.isAvailable()).resolves.toBe(false);
});
test("Codex reports available from a Microsoft Store install path when PATH misses codex", async () => {
const originalPlatform = process.platform;
const originalLocalAppData = process.env.LOCALAPPDATA;
const root = makeTempDir("provider-availability-codex-store-");
const emptyPathDir = join(root, "empty-path");
const codexBinDir = join(
root,
"Packages",
"OpenAI.Codex_abc123",
"LocalCache",
"Local",
"OpenAI",
"Codex",
"bin",
);
const codexExe = join(codexBinDir, "codex.exe");
mkdirSync(emptyPathDir, { recursive: true });
mkdirSync(codexBinDir, { recursive: true });
copyFileSync(process.execPath, codexExe);
Object.defineProperty(process, "platform", { value: "win32", writable: true });
process.env.LOCALAPPDATA = root;
isolatePathTo(emptyPathDir);
process.env.PATHEXT = ".EXE";
try {
const client = new CodexAppServerAgentClient(createTestLogger());
await expect(__codexAppServerInternals.findDefaultCodexBinary()).resolves.toBe(codexExe);
await expect(client.isAvailable()).resolves.toBe(true);
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, writable: true });
if (originalLocalAppData === undefined) {
delete process.env.LOCALAPPDATA;
} else {
process.env.LOCALAPPDATA = originalLocalAppData;
}
}
});
test("Claude reports unavailable when the default command cannot be resolved", async () => {
const binDir = makeTempDir("provider-availability-claude-");
isolatePathTo(binDir);
@@ -66,7 +118,7 @@ describe("default provider availability", () => {
test("AgentManager reports Codex unavailable without throwing", async () => {
const binDir = makeTempDir("provider-availability-manager-bin-");
isolatePathTo(binDir);
isolateCodexDefaultDiscoveryTo(binDir);
const workdir = makeTempDir("provider-availability-manager-work-");
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
const manager = new AgentManager({
@@ -88,7 +140,7 @@ describe("default provider availability", () => {
test("resumeAgentFromPersistence stops before provider spawn when Codex is unavailable", async () => {
const binDir = makeTempDir("provider-availability-resume-bin-");
isolatePathTo(binDir);
isolateCodexDefaultDiscoveryTo(binDir);
const workdir = makeTempDir("provider-availability-resume-work-");
const storage = new AgentStorage(join(workdir, "agents"), createTestLogger());
const manager = new AgentManager({
+10
View File
@@ -936,6 +936,16 @@ export async function createPaseoDaemon(
workspaceGitService,
github,
config.pushNotificationSender,
{
listen: formatListenTarget(boundListenTarget ?? listenTarget),
relay: {
enabled: relayEnabled,
endpoint: relayEndpoint,
publicEndpoint: relayPublicEndpoint,
useTls: relayUseTls,
publicUseTls: relayPublicUseTls,
},
},
);
if (relayEnabled) {
+214
View File
@@ -112,6 +112,7 @@ const checkoutGitMocks = vi.hoisted(() => ({
mergeToBase: vi.fn(),
pullCurrentBranch: vi.fn(),
pushCurrentBranch: vi.fn(),
renameCurrentBranch: vi.fn(),
resolveBranchCheckout: vi.fn(),
warmCheckoutShortstatInBackground: vi.fn(),
}));
@@ -203,6 +204,7 @@ vi.mock("../utils/checkout-git.js", async (importOriginal) => {
mergeToBase: checkoutGitMocks.mergeToBase,
pullCurrentBranch: checkoutGitMocks.pullCurrentBranch,
pushCurrentBranch: checkoutGitMocks.pushCurrentBranch,
renameCurrentBranch: checkoutGitMocks.renameCurrentBranch,
resolveBranchCheckout: checkoutGitMocks.resolveBranchCheckout,
warmCheckoutShortstatInBackground: checkoutGitMocks.warmCheckoutShortstatInBackground,
};
@@ -927,6 +929,16 @@ function createWorkspaceGitSnapshot(
};
}
function createTerminalManagerStub(options?: { setTerminalTitle?: ReturnType<typeof vi.fn> }): {
setTerminalTitle: ReturnType<typeof vi.fn>;
subscribeTerminalsChanged: ReturnType<typeof vi.fn>;
} {
return {
setTerminalTitle: options?.setTerminalTitle ?? vi.fn(),
subscribeTerminalsChanged: vi.fn(() => () => {}),
};
}
afterEach(() => {
vi.clearAllMocks();
});
@@ -3158,6 +3170,208 @@ describe("session checkout switch branch handling", () => {
});
});
describe("session checkout rename branch handling", () => {
test("rejects invalid branch slugs without renaming", async () => {
const messages: unknown[] = [];
const workspaceGitService = {
getSnapshot: vi.fn(),
peekSnapshot: vi.fn(),
};
const session = createSessionForTest({ workspaceGitService, messages });
await session.handleMessage({
type: "checkout.rename_branch.request",
cwd: "/tmp/repo",
branch: "Feature Name",
requestId: "request-rename-invalid",
});
expect(checkoutGitMocks.renameCurrentBranch).not.toHaveBeenCalled();
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "checkout.rename_branch.response",
payload: {
cwd: "/tmp/repo",
success: false,
currentBranch: null,
error: {
code: "UNKNOWN",
message:
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
},
requestId: "request-rename-invalid",
},
});
});
test("reports null current branch when branch rename fails", async () => {
const messages: unknown[] = [];
const workspaceGitService = {
getSnapshot: vi.fn(),
peekSnapshot: vi.fn(),
};
const session = createSessionForTest({ workspaceGitService, messages });
checkoutGitMocks.renameCurrentBranch.mockRejectedValue(new Error("branch already exists"));
await session.handleMessage({
type: "checkout.rename_branch.request",
cwd: "/tmp/repo",
branch: "feature/new-name",
requestId: "request-rename-failure",
});
expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith(
"/tmp/repo",
"feature/new-name",
);
expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled();
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "checkout.rename_branch.response",
payload: {
cwd: "/tmp/repo",
success: false,
currentBranch: null,
error: {
code: "UNKNOWN",
message: "branch already exists",
},
requestId: "request-rename-failure",
},
});
});
test("forces workspace git refresh after renaming the current branch", async () => {
const messages: unknown[] = [];
const github = { invalidate: vi.fn() };
const workspaceGitService = {
getSnapshot: vi.fn().mockResolvedValue(
createWorkspaceGitSnapshot("/tmp/repo", {
git: {
currentBranch: "feature/new-name",
isDirty: false,
},
}),
),
peekSnapshot: vi.fn(() =>
createWorkspaceGitSnapshot("/tmp/repo", {
git: { currentBranch: "feature/old-name" },
}),
),
};
const session = createSessionForTest({ github, workspaceGitService, messages });
checkoutGitMocks.renameCurrentBranch.mockResolvedValue({
previousBranch: "feature/old-name",
currentBranch: "feature/new-name",
});
await session.handleMessage({
type: "checkout.rename_branch.request",
cwd: "/tmp/repo",
branch: "feature/new-name",
requestId: "request-rename-success",
});
expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith(
"/tmp/repo",
"feature/new-name",
);
expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", {
force: true,
reason: "rename-branch",
});
expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" });
expect(messages).toContainEqual({
type: "checkout.rename_branch.response",
payload: {
cwd: "/tmp/repo",
success: true,
currentBranch: "feature/new-name",
error: null,
requestId: "request-rename-success",
},
});
});
});
describe("session terminal rename handling", () => {
test("rejects an empty terminal title without calling the terminal manager", async () => {
const messages: unknown[] = [];
const terminalManager = createTerminalManagerStub();
const session = createSessionForTest({ terminalManager, messages });
await session.handleMessage({
type: "terminal.rename.request",
terminalId: "terminal-1",
title: " ",
requestId: "request-empty-title",
});
expect(terminalManager.setTerminalTitle).not.toHaveBeenCalled();
expect(messages).toContainEqual({
type: "terminal.rename.response",
payload: {
requestId: "request-empty-title",
success: false,
error: "Title is required",
},
});
});
test("reports when the terminal manager cannot find the terminal", async () => {
const messages: unknown[] = [];
const terminalManager = createTerminalManagerStub({
setTerminalTitle: vi.fn(() => false),
});
const session = createSessionForTest({ terminalManager, messages });
await session.handleMessage({
type: "terminal.rename.request",
terminalId: "missing-terminal",
title: "Renamed terminal",
requestId: "request-missing-terminal",
});
expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith(
"missing-terminal",
"Renamed terminal",
);
expect(messages).toContainEqual({
type: "terminal.rename.response",
payload: {
requestId: "request-missing-terminal",
success: false,
error: "Terminal not found",
},
});
});
test("trims and sets a valid terminal title", async () => {
const messages: unknown[] = [];
const terminalManager = createTerminalManagerStub({
setTerminalTitle: vi.fn(() => true),
});
const session = createSessionForTest({ terminalManager, messages });
await session.handleMessage({
type: "terminal.rename.request",
terminalId: "terminal-1",
title: " Renamed terminal ",
requestId: "request-title-success",
});
expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith("terminal-1", "Renamed terminal");
expect(messages).toContainEqual({
type: "terminal.rename.response",
payload: {
requestId: "request-title-success",
success: true,
error: null,
},
});
});
});
describe("session branch suggestions handling", () => {
test("lists branch suggestions through the workspace git service", async () => {
const messages: unknown[] = [];
+209 -9
View File
@@ -20,6 +20,7 @@ import {
type FileExplorerRequest,
type FileDownloadTokenRequest,
type GitSetupOptions,
type CheckoutRenameBranchRequest,
type StartWorkspaceScriptRequest,
type CloseItemsRequest,
type SubscribeCheckoutDiffRequest,
@@ -47,6 +48,8 @@ import type { TurnDetectionProvider } from "./speech/turn-detection-provider.js"
import { maybePersistTtsDebugAudio } from "./agent/tts-debug.js";
import { isPaseoDictationDebugEnabled } from "./agent/recordings-debug.js";
import { listAvailableEditorTargets, openInEditorTarget } from "./editor-targets.js";
import { getPidLockInfo } from "./pid-lock.js";
import { generateLocalPairingOffer } from "./pairing-offer.js";
import {
DictationStreamManager,
type DictationStreamOutboundMessage,
@@ -192,7 +195,9 @@ import {
pullCurrentBranch,
pushCurrentBranch,
createPullRequest,
renameCurrentBranch,
} from "../utils/checkout-git.js";
import { validateBranchSlug } from "../utils/branch-slug.js";
import { getProjectIcon } from "../utils/project-icon.js";
import { expandTilde } from "../utils/path.js";
import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js";
@@ -306,8 +311,8 @@ type GitMutationRefreshReason =
| "disable-pr-auto-merge"
| "create-pr"
| "switch-branch"
| "create-branch"
| "rename-branch"
| "create-branch"
| "stash-push"
| "stash-pop"
| "create-worktree";
@@ -429,6 +434,7 @@ type ProcessingPhase = "idle" | "transcribing";
interface WorkspaceGitWatchTarget {
cwd: string;
workspaceId: string;
watchers: FSWatcher[];
debounceTimer: ReturnType<typeof setTimeout> | null;
refreshPromise: Promise<void> | null;
@@ -595,6 +601,18 @@ export interface SessionOptions {
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap;
providerOverrides?: Record<string, ProviderOverride>;
isDev?: boolean;
serverId?: string;
daemonVersion?: string;
daemonRuntimeConfig?: {
listen: string | null;
relay: {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
} | null;
};
}
export type SessionLifecycleIntent =
@@ -805,6 +823,9 @@ export class Session {
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
private readonly isDev: boolean;
private readonly serverId: string | undefined;
private readonly daemonVersion: string | undefined;
private readonly daemonRuntimeConfig: SessionOptions["daemonRuntimeConfig"];
private voiceModeAgentId: string | null = null;
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null;
@@ -850,6 +871,9 @@ export class Session {
agentProviderRuntimeSettings,
providerOverrides,
isDev,
serverId,
daemonVersion,
daemonRuntimeConfig,
} = options;
this.clientId = clientId;
this.appVersion = appVersion ?? null;
@@ -901,6 +925,9 @@ export class Session {
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings;
this.providerOverrides = providerOverrides;
this.isDev = isDev === true;
this.serverId = serverId;
this.daemonVersion = daemonVersion;
this.daemonRuntimeConfig = daemonRuntimeConfig;
this.abortController = new AbortController();
this.workspaceDirectory = new WorkspaceDirectory({
logger: this.sessionLogger,
@@ -1864,6 +1891,10 @@ export class Session {
payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() },
});
return undefined;
case "daemon.get_status.request":
return this.handleDaemonGetStatusRequest(msg);
case "daemon.get_pairing_offer.request":
return this.handleDaemonGetPairingOfferRequest(msg);
case "set_daemon_config_request":
this.emit({
type: "set_daemon_config_response",
@@ -2019,12 +2050,8 @@ export class Session {
return undefined;
case "checkout_switch_branch_request":
return this.handleCheckoutSwitchBranchRequest(msg);
case "stash_save_request":
return this.handleStashSaveRequest(msg);
case "stash_pop_request":
return this.handleStashPopRequest(msg);
case "stash_list_request":
return this.handleStashListRequest(msg);
case "checkout.rename_branch.request":
return this.handleCheckoutRenameBranchRequest(msg);
case "checkout_commit_request":
return this.handleCheckoutCommitRequest(msg);
case "checkout_merge_request":
@@ -2047,6 +2074,12 @@ export class Session {
return this.handlePullRequestTimelineRequest(msg);
case "github_search_request":
return this.handleGitHubSearchRequest(msg);
case "stash_save_request":
return this.handleStashSaveRequest(msg);
case "stash_pop_request":
return this.handleStashPopRequest(msg);
case "stash_list_request":
return this.handleStashListRequest(msg);
default:
return undefined;
}
@@ -3813,6 +3846,86 @@ export class Session {
}
}
private async handleDaemonGetStatusRequest(
msg: Extract<SessionInboundMessage, { type: "daemon.get_status.request" }>,
): Promise<void> {
try {
const pidInfo = await getPidLockInfo(this.paseoHome);
const providers = (await this.agentManager.listProviderAvailability()).map((p) => ({
provider: p.provider,
available: p.available,
error: p.error ?? null,
}));
this.emit({
type: "daemon.get_status.response",
payload: {
requestId: msg.requestId,
serverId: this.serverId ?? "",
version: this.daemonVersion ?? null,
pid: process.pid,
nodePath: process.execPath,
startedAt: pidInfo?.startedAt ?? null,
listen: this.daemonRuntimeConfig?.listen ?? null,
relay: this.daemonRuntimeConfig?.relay ?? null,
providers,
},
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to handle daemon status request");
this.emit({
type: "daemon.get_status.response",
payload: {
requestId: msg.requestId,
serverId: this.serverId ?? "",
version: this.daemonVersion ?? null,
pid: process.pid,
nodePath: process.execPath,
startedAt: null,
listen: null,
relay: null,
providers: [],
},
});
}
}
private async handleDaemonGetPairingOfferRequest(
msg: Extract<SessionInboundMessage, { type: "daemon.get_pairing_offer.request" }>,
): Promise<void> {
try {
const relay = this.daemonRuntimeConfig?.relay;
const pairing = await generateLocalPairingOffer({
paseoHome: this.paseoHome,
relayEnabled: relay?.enabled ?? true,
relayEndpoint: relay?.endpoint,
relayPublicEndpoint: relay?.publicEndpoint,
relayUseTls: relay?.useTls,
relayPublicUseTls: relay?.publicUseTls,
includeQr: true,
logger: this.sessionLogger,
});
this.emit({
type: "daemon.get_pairing_offer.response",
payload: {
requestId: msg.requestId,
url: pairing.url ?? "",
qr: pairing.qr ?? null,
relayEnabled: pairing.relayEnabled,
},
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to handle daemon pairing offer request");
this.emit({
type: "rpc_error",
payload: {
requestId: msg.requestId,
requestType: "daemon.get_pairing_offer.request",
error: error instanceof Error ? error.message : String(error),
},
});
}
}
private async handleListAvailableProvidersRequest(
msg: Extract<SessionInboundMessage, { type: "list_available_providers_request" }>,
): Promise<void> {
@@ -4826,16 +4939,35 @@ export class Session {
target.lastBranchName = workspace?.name ?? null;
}
private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void {
const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd));
if (!target) {
return;
}
const previousBranchName = target.lastBranchName;
if (branchName === previousBranchName) {
return;
}
target.lastBranchName = branchName;
this.onBranchChanged?.(target.workspaceId, previousBranchName, branchName);
}
private syncWorkspaceGitObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
for (const workspace of workspaces) {
this.syncWorkspaceGitObserver(workspace.workspaceDirectory, {
isGit: workspace.projectKind === "git",
workspaceId: workspace.id,
});
this.rememberWorkspaceGitDescriptorState(workspace.workspaceDirectory, workspace);
}
}
private syncWorkspaceGitObserver(cwd: string, options: { isGit: boolean }): void {
private syncWorkspaceGitObserver(
cwd: string,
options: { isGit: boolean; workspaceId: string },
): void {
const normalizedCwd = normalizePersistedWorkspaceId(cwd);
if (!options.isGit) {
this.removeWorkspaceGitSubscription(normalizedCwd);
@@ -4846,9 +4978,22 @@ export class Session {
return;
}
const target: WorkspaceGitWatchTarget = {
cwd: normalizedCwd,
workspaceId: options.workspaceId,
watchers: [],
debounceTimer: null,
refreshPromise: null,
refreshQueued: false,
latestDescriptorStateKey: null,
lastBranchName: null,
};
this.workspaceGitWatchTargets.set(normalizedCwd, target);
const subscription = this.workspaceGitService.registerWorkspace(
{ cwd: normalizedCwd },
(snapshot) => {
this.handleWorkspaceGitBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null);
void this.emitWorkspaceUpdateForCwd(normalizedCwd);
this.emitCheckoutStatusUpdate(normalizedCwd, snapshot);
},
@@ -4955,6 +5100,58 @@ export class Session {
}
}
private async handleCheckoutRenameBranchRequest(msg: CheckoutRenameBranchRequest): Promise<void> {
const { cwd, branch, requestId } = msg;
const validation = validateBranchSlug(branch);
if (!validation.valid) {
this.emit({
type: "checkout.rename_branch.response",
payload: {
cwd,
success: false,
currentBranch: null,
error: toCheckoutError(new Error(validation.error ?? "Invalid branch name")),
requestId,
},
});
return;
}
try {
const result = await renameCurrentBranch(cwd, branch);
await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true });
this.checkoutDiffManager.scheduleRefreshForCwd(cwd);
this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch);
// Push a workspace_update immediately so the sidebar/header reflect
// the new branch name without waiting for the background git watcher.
await this.emitWorkspaceUpdateForCwd(cwd);
this.emit({
type: "checkout.rename_branch.response",
payload: {
cwd,
success: true,
currentBranch: result.currentBranch,
error: null,
requestId,
},
});
} catch (error) {
this.emit({
type: "checkout.rename_branch.response",
payload: {
cwd,
success: false,
currentBranch: null,
error: toCheckoutError(error),
requestId,
},
});
}
}
// ---------------------------------------------------------------------------
// Stash handlers
// ---------------------------------------------------------------------------
@@ -6672,7 +6869,10 @@ export class Session {
private async emitWorkspaceUpdateForCwd(
cwd: string,
options?: { skipReconcile?: boolean; dedupeGitState?: boolean },
options?: {
skipReconcile?: boolean;
dedupeGitState?: boolean;
},
): Promise<void> {
const workspaces = await this.workspaceRegistry.list();
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
@@ -1,8 +1,11 @@
import { describe, expect, test, vi } from "vitest";
import path from "node:path";
import type pino from "pino";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
import { ScriptRouteStore } from "./script-proxy.js";
import { Session, type SessionOptions } from "./session.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js";
import type {
WorkspaceGitListener,
WorkspaceGitRuntimeSnapshot,
@@ -24,7 +27,7 @@ interface SessionInternals {
lastEmittedByWorkspaceId: Map<string, unknown>;
};
buildWorkspaceDescriptorMap: () => Promise<Map<string, unknown>>;
syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean }): void;
syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean; workspaceId: string }): void;
listAgentPayloads: () => Promise<unknown[]>;
}
@@ -92,7 +95,15 @@ function createWorkspaceRuntimeSnapshot(
};
}
function createSessionForWorkspaceGitWatchTests(): {
function createSessionForWorkspaceGitWatchTests(options?: {
onBranchChanged?: (
workspaceId: string,
oldBranch: string | null,
newBranch: string | null,
) => void;
scriptRouteStore?: ScriptRouteStore;
scriptRuntimeStore?: WorkspaceScriptRuntimeStore;
}): {
session: Session;
emitted: Array<{ type: string; payload: unknown }>;
projects: Map<string, ReturnType<typeof createPersistedProjectRecord>>;
@@ -221,6 +232,10 @@ function createSessionForWorkspaceGitWatchTests(): {
stt: null,
tts: null,
terminalManager: null,
scriptRouteStore: options?.scriptRouteStore,
scriptRuntimeStore: options?.scriptRuntimeStore,
onBranchChanged: options?.onBranchChanged,
getDaemonTcpPort: () => 6767,
});
asInternals<SessionInternals>(session).listAgentPayloads = async () => [];
@@ -305,7 +320,7 @@ describe("workspace git watch targets", () => {
sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]);
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith(
{ cwd: REPO_CWD },
@@ -364,7 +379,7 @@ describe("workspace git watch targets", () => {
lastEmittedByWorkspaceId: new Map(),
};
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
emitted.length = 0;
subscriptions[0]?.listener(
@@ -407,6 +422,74 @@ describe("workspace git watch targets", () => {
await session.cleanup();
});
test("updates running service script URLs when the git branch changes", async () => {
const routeStore = new ScriptRouteStore();
routeStore.registerRoute({
hostname: "app.old-branch.paseo.localhost",
port: 4321,
workspaceId: "ws-10",
projectSlug: "paseo",
scriptName: "app",
});
const runtimeStore = new WorkspaceScriptRuntimeStore();
runtimeStore.set({
workspaceId: "ws-10",
scriptName: "app",
type: "service",
lifecycle: "running",
terminalId: "term-app",
exitCode: null,
});
const handleBranchChange = createBranchChangeRouteHandler({
routeStore,
onRoutesChanged: vi.fn(),
});
const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(
{
scriptRouteStore: routeStore,
scriptRuntimeStore: runtimeStore,
onBranchChanged: handleBranchChange,
},
);
const sessionAny = session as unknown as SessionInternals;
seedGitWorkspace({
projects,
workspaces,
projectId: "proj-1",
workspaceId: "ws-10",
cwd: "/tmp/repo",
name: "old-branch",
});
sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true, workspaceId: "ws-10" });
subscriptions[0]?.listener(
createWorkspaceRuntimeSnapshot("/tmp/repo", {
git: {
currentBranch: "new-branch",
},
}),
);
expect(routeStore.listRoutesForWorkspace("ws-10")).toEqual([
expect.objectContaining({
hostname: "app.new-branch.paseo.localhost",
projectSlug: "paseo",
scriptName: "app",
}),
]);
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
expect.objectContaining({
scriptName: "app",
hostname: "app.new-branch.paseo.localhost",
proxyUrl: "http://app.new-branch.paseo.localhost:6767",
}),
]);
await session.cleanup();
});
test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => {
const { session, emitted, projects, workspaces, subscriptions } =
createSessionForWorkspaceGitWatchTests();
@@ -427,7 +510,7 @@ describe("workspace git watch targets", () => {
lastEmittedByWorkspaceId: new Map(),
};
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true });
sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" });
emitted.length = 0;
subscriptions[0]?.listener(
@@ -332,6 +332,18 @@ export class VoiceAssistantWebSocketServer {
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
private readonly serverId: string;
private readonly daemonVersion: string;
private readonly daemonRuntimeConfig:
| {
listen: string | null;
relay: {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
};
}
| undefined;
private readonly agentManager: AgentManager;
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
@@ -416,6 +428,16 @@ export class VoiceAssistantWebSocketServer {
workspaceGitService?: WorkspaceGitService,
github?: GitHubService,
pushNotificationSender?: PushNotificationSender,
daemonRuntimeConfig?: {
listen: string | null;
relay: {
enabled: boolean;
endpoint: string;
publicEndpoint: string;
useTls: boolean;
publicUseTls: boolean;
};
},
) {
this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId;
@@ -423,6 +445,7 @@ export class VoiceAssistantWebSocketServer {
throw new MissingDaemonVersionError();
}
this.daemonVersion = daemonVersion.trim();
this.daemonRuntimeConfig = daemonRuntimeConfig;
this.agentManager = agentManager;
this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
@@ -921,6 +944,9 @@ export class VoiceAssistantWebSocketServer {
agentProviderRuntimeSettings: this.agentProviderRuntimeSettings,
providerOverrides: this.providerOverrides,
isDev: this.isDev,
serverId: this.serverId,
daemonVersion: this.daemonVersion,
daemonRuntimeConfig: this.daemonRuntimeConfig,
});
connection = {
@@ -1053,6 +1079,8 @@ export class VoiceAssistantWebSocketServer {
providersSnapshot: true,
// COMPAT(checkoutGithubSetAutoMerge): added in v0.1.75, remove gate after 2026-11-13.
checkoutGithubSetAutoMerge: true,
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: true,
},
};
}
@@ -0,0 +1,140 @@
import { z } from "zod";
import { describe, expect, test } from "vitest";
import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js";
type SessionMessageOption = z.ZodDiscriminatedUnionOption<"type">;
function schemaWithoutMessageTypes(
schema: { options: SessionMessageOption[] },
excludedTypes: string[],
) {
const excluded = new Set(excludedTypes);
const options = schema.options.filter((option) => !excluded.has(option.shape.type.value));
return z.discriminatedUnion("type", options as [SessionMessageOption, ...SessionMessageOption[]]);
}
describe("rename entity message schemas", () => {
test("new client schema still parses old daemon checkout and terminal responses", () => {
const checkoutResponse = SessionOutboundMessageSchema.parse({
type: "checkout_switch_branch_response",
payload: {
cwd: "/tmp/repo",
success: true,
branch: "main",
source: "local",
error: null,
requestId: "request-switch",
},
});
const terminalResponse = SessionOutboundMessageSchema.parse({
type: "kill_terminal_response",
payload: {
terminalId: "terminal-1",
success: true,
requestId: "request-kill",
},
});
expect(checkoutResponse).toEqual({
type: "checkout_switch_branch_response",
payload: {
cwd: "/tmp/repo",
success: true,
branch: "main",
source: "local",
error: null,
requestId: "request-switch",
},
});
expect(terminalResponse).toEqual({
type: "kill_terminal_response",
payload: {
terminalId: "terminal-1",
success: true,
requestId: "request-kill",
},
});
});
test("old unions without rename variants reject rename messages and still parse existing messages", () => {
const legacyInboundSchema = schemaWithoutMessageTypes(SessionInboundMessageSchema, [
"terminal.rename.request",
"checkout.rename_branch.request",
]);
const legacyOutboundSchema = schemaWithoutMessageTypes(SessionOutboundMessageSchema, [
"terminal.rename.response",
"checkout.rename_branch.response",
]);
expect(
legacyInboundSchema.safeParse({
type: "terminal.rename.request",
terminalId: "terminal-1",
title: "Server logs",
requestId: "request-terminal-rename",
}).success,
).toBe(false);
expect(
legacyInboundSchema.safeParse({
type: "checkout.rename_branch.request",
cwd: "/tmp/repo",
branch: "feature/new-name",
requestId: "request-branch-rename",
}).success,
).toBe(false);
expect(
legacyOutboundSchema.safeParse({
type: "terminal.rename.response",
payload: {
requestId: "request-terminal-rename",
success: true,
error: null,
},
}).success,
).toBe(false);
expect(
legacyOutboundSchema.safeParse({
type: "checkout.rename_branch.response",
payload: {
requestId: "request-branch-rename",
success: true,
cwd: "/tmp/repo",
currentBranch: "feature/new-name",
error: null,
},
}).success,
).toBe(false);
expect(
legacyInboundSchema.parse({
type: "checkout_switch_branch_request",
cwd: "/tmp/repo",
branch: "main",
requestId: "request-switch",
}),
).toEqual({
type: "checkout_switch_branch_request",
cwd: "/tmp/repo",
branch: "main",
requestId: "request-switch",
});
expect(
legacyOutboundSchema.parse({
type: "kill_terminal_response",
payload: {
terminalId: "terminal-1",
success: true,
requestId: "request-kill",
},
}),
).toEqual({
type: "kill_terminal_response",
payload: {
terminalId: "terminal-1",
success: true,
requestId: "request-kill",
},
});
});
});
+104
View File
@@ -979,6 +979,16 @@ export const WaitForFinishRequestSchema = z.object({
timeoutMs: z.number().int().positive().optional(),
});
export const DaemonGetStatusRequestSchema = z.object({
type: z.literal("daemon.get_status.request"),
requestId: z.string(),
});
export const DaemonGetPairingOfferRequestSchema = z.object({
type: z.literal("daemon.get_pairing_offer.request"),
requestId: z.string(),
});
export const GetDaemonConfigRequestMessageSchema = z.object({
type: z.literal("get_daemon_config_request"),
requestId: z.string(),
@@ -1379,6 +1389,13 @@ export const CheckoutSwitchBranchRequestSchema = z.object({
requestId: z.string(),
});
export const CheckoutRenameBranchRequestSchema = z.object({
type: z.literal("checkout.rename_branch.request"),
cwd: z.string(),
branch: z.string(),
requestId: z.string(),
});
export const StashSaveRequestSchema = z.object({
type: z.literal("stash_save_request"),
cwd: z.string(),
@@ -1696,6 +1713,13 @@ export const CreateTerminalRequestSchema = z.object({
requestId: z.string(),
});
export const RenameTerminalRequestSchema = z.object({
type: z.literal("terminal.rename.request"),
terminalId: z.string(),
title: z.string(),
requestId: z.string(),
});
export const StartWorkspaceScriptRequestSchema = z.object({
type: z.literal("start_workspace_script_request"),
workspaceId: z.string(),
@@ -1764,6 +1788,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetVoiceModeMessageSchema,
SendAgentMessageRequestSchema,
WaitForFinishRequestSchema,
DaemonGetStatusRequestSchema,
DaemonGetPairingOfferRequestSchema,
GetDaemonConfigRequestMessageSchema,
SetDaemonConfigRequestMessageSchema,
ReadProjectConfigRequestMessageSchema,
@@ -1806,6 +1832,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPrStatusRequestSchema,
PullRequestTimelineRequestSchema,
CheckoutSwitchBranchRequestSchema,
CheckoutRenameBranchRequestSchema,
StashSaveRequestSchema,
StashPopRequestSchema,
StashListRequestSchema,
@@ -1833,6 +1860,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SubscribeTerminalsRequestSchema,
UnsubscribeTerminalsRequestSchema,
CreateTerminalRequestSchema,
RenameTerminalRequestSchema,
StartWorkspaceScriptRequestSchema,
SubscribeTerminalRequestSchema,
UnsubscribeTerminalRequestSchema,
@@ -2026,6 +2054,8 @@ export const ServerInfoStatusPayloadSchema = z
.object({
providersSnapshot: z.boolean().optional(),
checkoutGithubSetAutoMerge: z.boolean().optional(),
// COMPAT(daemonStatusRpc): added in v0.1.76, remove gate after 2026-11-18.
daemonStatusRpc: z.boolean().optional(),
})
.optional(),
})
@@ -2586,6 +2616,50 @@ export const GetDaemonConfigResponseMessageSchema = z.object({
.passthrough(),
});
export const DaemonGetStatusResponseSchema = z.object({
type: z.literal("daemon.get_status.response"),
payload: z
.object({
requestId: z.string(),
serverId: z.string(),
version: z.string().nullable().optional(),
pid: z.number(),
nodePath: z.string(),
startedAt: z.string().nullable().optional(),
listen: z.string().nullable(),
relay: z
.object({
enabled: z.boolean(),
endpoint: z.string(),
publicEndpoint: z.string(),
useTls: z.boolean(),
publicUseTls: z.boolean(),
})
.nullable()
.optional(),
providers: z.array(
z.object({
provider: z.string(),
available: z.boolean(),
error: z.string().nullable().optional(),
}),
),
})
.passthrough(),
});
export const DaemonGetPairingOfferResponseSchema = z.object({
type: z.literal("daemon.get_pairing_offer.response"),
payload: z
.object({
requestId: z.string(),
url: z.string(),
qr: z.string().nullable().optional(),
relayEnabled: z.boolean(),
})
.passthrough(),
});
export const SetDaemonConfigResponseMessageSchema = z.object({
type: z.literal("set_daemon_config_response"),
payload: z
@@ -3047,6 +3121,17 @@ export const CheckoutSwitchBranchResponseSchema = z.object({
}),
});
export const CheckoutRenameBranchResponseSchema = z.object({
type: z.literal("checkout.rename_branch.response"),
payload: z.object({
requestId: z.string(),
success: z.boolean(),
cwd: z.string(),
currentBranch: z.string().nullable(),
error: CheckoutErrorSchema.nullable(),
}),
});
const StashEntrySchema = z.object({
index: z.number().int().min(0),
message: z.string(),
@@ -3401,6 +3486,15 @@ export const CreateTerminalResponseSchema = z.object({
}),
});
export const RenameTerminalResponseSchema = z.object({
type: z.literal("terminal.rename.response"),
payload: z.object({
requestId: z.string(),
success: z.boolean(),
error: z.string().nullable(),
}),
});
export const SubscribeTerminalResponseSchema = z.object({
type: z.literal("subscribe_terminal_response"),
payload: z.union([
@@ -3481,6 +3575,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ClearAgentAttentionResponseMessageSchema,
SendAgentMessageResponseMessageSchema,
SetVoiceModeResponseMessageSchema,
DaemonGetStatusResponseSchema,
DaemonGetPairingOfferResponseSchema,
GetDaemonConfigResponseMessageSchema,
SetDaemonConfigResponseMessageSchema,
ReadProjectConfigResponseMessageSchema,
@@ -3512,6 +3608,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPrStatusResponseSchema,
PullRequestTimelineResponseSchema,
CheckoutSwitchBranchResponseSchema,
CheckoutRenameBranchResponseSchema,
StashSaveResponseSchema,
StashPopResponseSchema,
StashListResponseSchema,
@@ -3537,6 +3634,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ListTerminalsResponseSchema,
TerminalsChangedSchema,
CreateTerminalResponseSchema,
RenameTerminalResponseSchema,
SubscribeTerminalResponseSchema,
KillTerminalResponseSchema,
CaptureTerminalResponseSchema,
@@ -3643,6 +3741,8 @@ export type ListProviderFeaturesResponseMessage = z.infer<
typeof ListProviderFeaturesResponseMessageSchema
>;
export type ListAvailableProvidersResponse = z.infer<typeof ListAvailableProvidersResponseSchema>;
export type DaemonGetStatusResponse = z.infer<typeof DaemonGetStatusResponseSchema>;
export type DaemonGetPairingOfferResponse = z.infer<typeof DaemonGetPairingOfferResponseSchema>;
export type GetProvidersSnapshotResponseMessage = z.infer<
typeof GetProvidersSnapshotResponseMessageSchema
>;
@@ -3782,6 +3882,8 @@ export type PullRequestTimelineItem = z.infer<typeof PullRequestTimelineItemSche
export type PullRequestTimelineResponse = z.infer<typeof PullRequestTimelineResponseSchema>;
export type CheckoutSwitchBranchRequest = z.infer<typeof CheckoutSwitchBranchRequestSchema>;
export type CheckoutSwitchBranchResponse = z.infer<typeof CheckoutSwitchBranchResponseSchema>;
export type CheckoutRenameBranchRequest = z.infer<typeof CheckoutRenameBranchRequestSchema>;
export type CheckoutRenameBranchResponse = z.infer<typeof CheckoutRenameBranchResponseSchema>;
export type StashSaveRequest = z.infer<typeof StashSaveRequestSchema>;
export type StashSaveResponse = z.infer<typeof StashSaveResponseSchema>;
export type StashPopRequest = z.infer<typeof StashPopRequestSchema>;
@@ -3835,6 +3937,8 @@ export type UnsubscribeTerminalsRequest = z.infer<typeof UnsubscribeTerminalsReq
export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>;
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
export type RenameTerminalRequest = z.infer<typeof RenameTerminalRequestSchema>;
export type RenameTerminalResponse = z.infer<typeof RenameTerminalResponseSchema>;
export type StartWorkspaceScriptRequest = z.infer<typeof StartWorkspaceScriptRequestSchema>;
export type StartWorkspaceScriptResponse = z.infer<
typeof StartWorkspaceScriptResponseMessageSchema
@@ -384,3 +384,35 @@ it("emits empty snapshot when last terminal is removed", async () => {
unsubscribe();
});
it("setTerminalTitle returns false for unknown terminal ids without changing existing terminals", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({
cwd: realpathSync(tmpdir()),
title: "Existing title",
});
const snapshots: Array<Array<{ id: string; title?: string }>> = [];
const unsubscribe = manager.subscribeTerminalsChanged((input) => {
snapshots.push(
input.terminals.map((terminal) => ({
id: terminal.id,
...(terminal.title ? { title: terminal.title } : {}),
})),
);
});
expect(manager.setTerminalTitle("unknown-id", "x")).toBe(false);
expect(session.getTitle()).toBe("Existing title");
expect(session.getState().title).toBe("Existing title");
expect(snapshots).toEqual([]);
unsubscribe();
});
it("setTerminalTitle returns true and updates the terminal title for existing terminals", async () => {
manager = createTerminalManager();
const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) });
expect(manager.setTerminalTitle(session.id, "x")).toBe(true);
expect(session.getTitle()).toBe("x");
});
@@ -30,6 +30,7 @@ export interface TerminalManager {
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
getTerminal(id: string): TerminalSession | undefined;
getTerminalState(id: string): Promise<TerminalStateSnapshot | null>;
setTerminalTitle(id: string, title: string): boolean;
killTerminal(id: string): void;
killTerminalAndWait(
id: string,
@@ -211,6 +212,16 @@ export function createTerminalManager(): TerminalManager {
return terminalsById.get(id)?.getStateSnapshot() ?? null;
},
setTerminalTitle(id: string, title: string): boolean {
const session = terminalsById.get(id);
if (!session) {
return false;
}
session.setTitle(title);
return true;
},
killTerminal(id: string): void {
removeSessionById(id, { kill: true });
},
@@ -4,6 +4,7 @@ import type {
CreateTerminalRequest,
KillTerminalRequest,
ListTerminalsRequest,
RenameTerminalRequest,
SessionInboundMessage,
SessionOutboundMessage,
SubscribeTerminalRequest,
@@ -64,7 +65,8 @@ type TerminalDispatchableMessage =
| UnsubscribeTerminalRequest
| TerminalInput
| KillTerminalRequest
| CaptureTerminalRequest;
| CaptureTerminalRequest
| RenameTerminalRequest;
const TERMINAL_MESSAGE_TYPES: ReadonlySet<TerminalDispatchableMessage["type"]> = new Set([
"subscribe_terminals_request",
@@ -76,6 +78,7 @@ const TERMINAL_MESSAGE_TYPES: ReadonlySet<TerminalDispatchableMessage["type"]> =
"terminal_input",
"kill_terminal_request",
"capture_terminal_request",
"terminal.rename.request",
]);
export class TerminalSessionController {
@@ -145,6 +148,8 @@ export class TerminalSessionController {
return this.handleKillTerminalRequest(msg);
case "capture_terminal_request":
return this.handleCaptureTerminalRequest(msg);
case "terminal.rename.request":
return this.handleRenameTerminalRequest(msg);
default:
return undefined;
}
@@ -430,6 +435,32 @@ export class TerminalSessionController {
}
}
private async handleRenameTerminalRequest(msg: RenameTerminalRequest): Promise<void> {
const respond = (success: boolean, error: string | null): void => {
this.emit({
type: "terminal.rename.response",
payload: { requestId: msg.requestId, success, error },
});
};
const title = msg.title.trim();
if (title.length === 0) {
respond(false, "Title is required");
return;
}
if (title.length > 200) {
respond(false, "Title is too long");
return;
}
if (!this.terminalManager) {
respond(false, "Terminal manager not available");
return;
}
const renamed = this.terminalManager.setTerminalTitle(msg.terminalId, title);
respond(renamed, renamed ? null : "Terminal not found");
}
private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise<void> {
if (!this.terminalManager) {
this.emit({
+639 -1
View File
@@ -1,15 +1,19 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, vi } from "vitest";
import { isPlatform } from "../test-utils/platform.js";
import {
buildTerminalEnvironment,
createTerminal,
ensureNodePtySpawnHelperExecutableForCurrentPlatform,
resolveDefaultTerminalShell,
humanizeProcessTitle,
normalizeProcessTitle,
resolveZshShellIntegrationDir,
type TerminalSession,
} from "./terminal.js";
import {
chmodSync,
cpSync,
existsSync,
mkdtempSync,
mkdirSync,
realpathSync,
@@ -17,8 +21,92 @@ import {
statSync,
writeFileSync,
} from "node:fs";
import { spawnSync } from "node:child_process";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { setImmediate as waitForImmediate } from "node:timers/promises";
const hasZsh = existsSync("/bin/zsh");
type TerminalRow = ReturnType<TerminalSession["getState"]>["grid"][number];
function rowToText(row: TerminalRow): string {
return row
.map((cell) => cell.char)
.join("")
.trimEnd();
}
// Extract text from a single row
function getRowText(state: ReturnType<TerminalSession["getState"]>, rowIndex: number): string {
return rowToText(state.grid[rowIndex]);
}
// Extract all visible lines as array (trimmed, empty lines included)
function getLines(state: ReturnType<TerminalSession["getState"]>): string[] {
return state.grid.map(rowToText);
}
// Wait for terminal state to match expected lines
async function waitForLines(
session: TerminalSession,
expectedLines: string[],
timeoutMs = 5000,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const lines = getLines(session.getState());
let matches = true;
for (let i = 0; i < expectedLines.length; i++) {
if (lines[i] !== expectedLines[i]) {
matches = false;
break;
}
}
if (matches) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
const actual = getLines(session.getState()).slice(0, expectedLines.length);
throw new Error(
`Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`,
);
}
async function waitForState(
session: TerminalSession,
predicate: (state: ReturnType<TerminalSession["getState"]>) => boolean,
timeoutMs = 5000,
): Promise<ReturnType<TerminalSession["getState"]>> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const state = session.getState();
if (predicate(state)) {
return state;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error("Timeout waiting for terminal state predicate to match");
}
async function waitForTitle(
session: TerminalSession,
predicate: (title: string | undefined) => boolean,
timeoutMs = 5000,
): Promise<string | undefined> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const title = session.getTitle();
if (predicate(title)) {
return title;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error("Timeout waiting for terminal title predicate to match");
}
if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) {
process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe";
@@ -28,6 +116,7 @@ const sessions: TerminalSession[] = [];
const temporaryDirs: string[] = [];
afterEach(async () => {
vi.useRealTimers();
for (const session of sessions) {
session.kill();
}
@@ -45,6 +134,17 @@ function trackSession(session: TerminalSession): TerminalSession {
return session;
}
async function waitForScheduledTimers(expectedTimerCount: number): Promise<void> {
for (let attempt = 0; attempt < 100; attempt++) {
if (vi.getTimerCount() === expectedTimerCount) {
return;
}
await waitForImmediate();
}
throw new Error(`Expected ${expectedTimerCount} scheduled timers, got ${vi.getTimerCount()}`);
}
describe("createTerminal", () => {
it("keeps full process titles while stripping path prefixes", () => {
expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev");
@@ -205,6 +305,544 @@ describe("createTerminal", () => {
});
});
describe.skipIf(isPlatform("win32"))("send input", () => {
it("executes a simple echo command", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
// Wait for initial prompt, then send command
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "echo hello\r" });
// After running "echo hello", terminal should show:
// Line 0: "$ echo hello"
// Line 1: "hello"
// Line 2: "$"
await waitForLines(session, ["$ echo hello", "hello", "$"]);
const state = session.getState();
expect(getRowText(state, 0)).toBe("$ echo hello");
expect(getRowText(state, 1)).toBe("hello");
expect(getRowText(state, 2)).toBe("$");
});
it("captures output from pwd in specified cwd", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "pwd\r" });
await waitForLines(session, ["$ pwd", "/tmp", "$"]);
const state = session.getState();
expect(getRowText(state, 0)).toBe("$ pwd");
expect(getRowText(state, 1)).toBe("/tmp");
expect(getRowText(state, 2)).toBe("$");
});
});
describe.skipIf(isPlatform("win32"))("terminal title", () => {
it.skipIf(!hasZsh)("restores the user's ZDOTDIR through the zsh wrapper", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-"));
temporaryDirs.push(homeDir);
const realZdotdir = join(homeDir, ".config", "zsh");
mkdirSync(realZdotdir, { recursive: true });
writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n");
const session = trackSession(
await createTerminal({
cwd: homeDir,
command: "/bin/zsh",
args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'],
env: {
HOME: homeDir,
ZDOTDIR: realZdotdir,
},
}),
);
const exitInfo = await new Promise<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
(resolve) => {
session.onExit((info) => resolve(info));
},
);
expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]);
});
it("emits the initial title from command args to title listeners", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "npm-cli.js");
writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n");
const session = trackSession(
await createTerminal({
cwd: packageRoot,
command: process.execPath,
args: [scriptPath, "run", "dev"],
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForTitle(session, (title) => title === "npm run dev");
await waitForState(session, (state) => state.title === "npm run dev");
expect(seenTitles).toContain("npm run dev");
expect(session.getTitle()).toBe("npm run dev");
expect(session.getState().title).toBe("npm run dev");
unsubscribeTitle();
});
it("emits OSC title updates to title listeners", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await waitForTitle(session, (title) => title === "Build Log");
expect(seenTitles).toContain("Build Log");
expect(session.getTitle()).toBe("Build Log");
expect(session.getState().title).toBe("Build Log");
unsubscribeTitle();
});
it("keeps preset titles instead of applying OSC title updates", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
title: "typecheck",
}),
);
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await new Promise((resolve) => setTimeout(resolve, 150));
expect(session.getTitle()).toBe("typecheck");
expect(session.getState().title).toBe("typecheck");
});
it("emits command completion from VS Code OSC 633 without visible output", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "emit-command-finished.sh");
writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n");
chmodSync(scriptPath, 0o755);
const session = trackSession(
await createTerminal({
cwd: packageRoot,
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const commandCompletions: Array<number | null> = [];
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
commandCompletions.push(info.exitCode);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "./emit-command-finished.sh\r" });
await waitForState(session, () => commandCompletions.length === 1);
expect(commandCompletions).toEqual([7]);
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7");
unsubscribeCommandFinished();
});
it("ignores malformed VS Code OSC 633 command completion payloads", async () => {
const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-"));
temporaryDirs.push(packageRoot);
const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh");
writeFileSync(
scriptPath,
"#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n",
);
chmodSync(scriptPath, 0o755);
const session = trackSession(
await createTerminal({
cwd: packageRoot,
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const commandCompletions: Array<number | null> = [];
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
commandCompletions.push(info.exitCode);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" });
await waitForState(session, () => commandCompletions.length === 1);
expect(commandCompletions).toEqual([3]);
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage");
unsubscribeCommandFinished();
});
it("debounces rapid title changes and emits only the final title", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const seenMessages: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
const unsubscribeMessages = session.subscribe((message) => {
if (message.type === "titleChange") {
seenMessages.push(message.title);
}
});
await waitForLines(session, ["$"]);
session.send({
type: "input",
data: "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r",
});
await waitForTitle(session, (title) => title === "Final");
expect(seenTitles).toEqual(["Final"]);
expect(seenMessages).toEqual(["Final"]);
unsubscribeMessages();
unsubscribeTitle();
});
it.skipIf(!hasZsh)("emits zsh shell integration titles for commands and prompts", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-"));
temporaryDirs.push(homeDir);
const realZdotdir = join(homeDir, ".config", "zsh");
const workingDir = join(homeDir, "dev", "faro");
mkdirSync(realZdotdir, { recursive: true });
mkdirSync(workingDir, { recursive: true });
writeFileSync(join(realZdotdir, ".zshenv"), "");
writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n");
const session = trackSession(
await createTerminal({
cwd: workingDir,
shell: "/bin/zsh",
env: {
HOME: homeDir,
ZDOTDIR: realZdotdir,
},
}),
);
await waitForLines(session, ["$"]);
await waitForTitle(session, (title) => title === "~/dev/faro");
session.send({ type: "input", data: "sleep 1\r" });
await waitForTitle(session, (title) => title === "sleep 1");
await waitForTitle(session, (title) => title === "~/dev/faro", 4000);
});
it.skipIf(!hasZsh)("loads the user's zsh prompt when the integration dir is packaged", () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-packaged-home-"));
temporaryDirs.push(homeDir);
writeFileSync(join(homeDir, ".zshrc"), "PS1='PASEO_CUSTOM_PROMPT> '\n");
const fakeAppRoot = join(homeDir, "Paseo.app", "Contents", "Resources");
const inaccessiblePackagedIntegrationDir = join(
fakeAppRoot,
"app.asar",
"node_modules",
"@getpaseo",
"server",
"dist",
"server",
"terminal",
"shell-integration",
"zsh",
);
const unpackedIntegrationDir = join(
fakeAppRoot,
"app.asar.unpacked",
"node_modules",
"@getpaseo",
"server",
"dist",
"server",
"terminal",
"shell-integration",
"zsh",
);
mkdirSync(unpackedIntegrationDir, { recursive: true });
cpSync(resolveZshShellIntegrationDir(), unpackedIntegrationDir, { recursive: true });
writeFileSync(join(fakeAppRoot, "app.asar"), "asar archive placeholder");
const env = buildTerminalEnvironment({
shell: "/bin/zsh",
env: {
HOME: homeDir,
},
zshShellIntegrationDir: inaccessiblePackagedIntegrationDir,
});
const result = spawnSync("/bin/zsh", ["-i", "-c", "print -r -- ${PROMPT}"], {
cwd: homeDir,
env,
encoding: "utf8",
});
expect(result.status).toBe(0);
expect(result.stdout.split(/\r?\n/)).toContain("PASEO_CUSTOM_PROMPT> ");
});
it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-"));
temporaryDirs.push(homeDir);
const realZdotdir = join(homeDir, ".config", "zsh");
const workingDir = join(homeDir, "dev", "faro");
mkdirSync(realZdotdir, { recursive: true });
mkdirSync(workingDir, { recursive: true });
writeFileSync(join(realZdotdir, ".zshenv"), "");
writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n");
const session = trackSession(
await createTerminal({
cwd: workingDir,
shell: "/bin/zsh",
env: {
HOME: homeDir,
ZDOTDIR: realZdotdir,
},
}),
);
const commandCompletions: Array<number | null> = [];
const unsubscribeCommandFinished = session.onCommandFinished((info) => {
commandCompletions.push(info.exitCode);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "false\r" });
await waitForState(session, () => commandCompletions.includes(1));
expect(commandCompletions).toEqual([1]);
expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1");
unsubscribeCommandFinished();
});
it("clears already scheduled OSC title debounce timers when setting a user title", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await waitForTitle(session, (title) => title === "Build Log");
vi.useFakeTimers();
session.send({ type: "input", data: "printf '\\033]0;Pending Shell Title\\007'\r" });
await waitForScheduledTimers(1);
session.setTitle("User terminal");
await vi.advanceTimersByTimeAsync(250);
vi.useRealTimers();
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
expect(session.getTitle()).toBe("User terminal");
expect(session.getState().title).toBe("User terminal");
unsubscribeTitle();
});
it("ignores later OSC title updates after setting a user title", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForLines(session, ["$"]);
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await waitForTitle(session, (title) => title === "Build Log");
session.setTitle("User terminal");
session.send({ type: "input", data: "printf '\\033]0;Later Shell Title\\007'\r" });
await new Promise((resolve) => setTimeout(resolve, 250));
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
expect(session.getTitle()).toBe("User terminal");
expect(session.getState().title).toBe("User terminal");
unsubscribeTitle();
});
it("trims user-set titles and treats empty titles as no-ops", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ " },
}),
);
const seenTitles: Array<string | undefined> = [];
const unsubscribeTitle = session.onTitleChange((title) => {
seenTitles.push(title);
});
await waitForLines(session, ["$"]);
session.setTitle(" ");
session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" });
await waitForTitle(session, (title) => title === "Build Log");
session.setTitle(" User terminal ");
expect(seenTitles).toEqual(["Build Log", "User terminal"]);
expect(session.getTitle()).toBe("User terminal");
expect(session.getState().title).toBe("User terminal");
unsubscribeTitle();
});
});
describe.skipIf(isPlatform("win32"))("colors", () => {
it("captures ANSI 16 color codes (mode 1)", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
}),
);
await waitForLines(session, ["$"]);
// \033[31m = ANSI red (color 1)
session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" });
await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]);
const state = session.getState();
const outputRow = state.grid[1];
expect(outputRow[0].char).toBe("R");
expect(outputRow[0].fg).toBe(1); // ANSI red = 1
expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors
// The "$" after RED should have default color
expect(outputRow[3].char).toBe("$");
expect(outputRow[3].fg).toBe(undefined);
expect(outputRow[3].fgMode).toBe(undefined);
});
it("captures true color RGB (mode 3)", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
}),
);
await waitForLines(session, ["$"]);
// \033[38;2;255;128;64m = true color RGB(255, 128, 64)
session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" });
await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]);
const state = session.getState();
const outputRow = state.grid[1];
// Check R cell
expect(outputRow[0].char).toBe("R");
expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color
// The color value should be packed RGB: (255 << 16) | (128 << 8) | 64
const expectedPacked = (255 << 16) | (128 << 8) | 64;
expect(outputRow[0].fg).toBe(expectedPacked);
});
it("captures background colors", async () => {
const session = trackSession(
await createTerminal({
cwd: "/tmp",
shell: "/bin/sh",
env: { PS1: "$ ", TERM: "xterm-256color" },
}),
);
await waitForLines(session, ["$"]);
// \033[41m = ANSI red background
session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" });
await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]);
const state = session.getState();
const outputRow = state.grid[1];
expect(outputRow[0].char).toBe("B");
expect(outputRow[0].bg).toBe(1); // ANSI red = 1
expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors
});
});
describe("resize", () => {
it("updates terminal dimensions on resize", async () => {
const session = trackSession(
+37 -10
View File
@@ -57,6 +57,7 @@ export interface TerminalSession {
getStateSnapshot(): TerminalStateSnapshot;
getReplayPreamble(): string;
getTitle(): string | undefined;
setTitle(title: string): void;
getExitInfo(): TerminalExitInfo | null;
kill(): void;
killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise<void>;
@@ -549,12 +550,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
let exitInfo: TerminalExitInfo | null = null;
let recentOutputText = "";
let title: string | undefined;
let titleMode: "auto" | "manual" = presetTitle?.trim() ? "manual" : "auto";
let pendingTitle: string | undefined;
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let pendingInput = "";
let inputFlushImmediate: ReturnType<typeof setImmediate> | null = null;
let stateRevision = 0;
const inputModeTracker = new TerminalInputModeTracker();
let titleChangeSubscription: { dispose(): void } | null = null;
// Create xterm.js headless terminal
const terminal = new Terminal({
@@ -598,14 +601,40 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
}
}
const lockedTitle = presetTitle?.trim() || undefined;
function clearPendingTitleChange(): void {
pendingTitle = undefined;
if (titleDebounceTimer) {
clearTimeout(titleDebounceTimer);
titleDebounceTimer = null;
}
}
function disposeTitleChangeSubscription(): void {
titleChangeSubscription?.dispose();
titleChangeSubscription = null;
}
function setTitle(nextTitle: string): void {
const manualTitle = nextTitle.trim();
if (!manualTitle) {
return;
}
titleMode = "manual";
disposeTitleChangeSubscription();
clearPendingTitleChange();
emitTitleChange(manualTitle);
}
const initialManualTitle = presetTitle?.trim() || undefined;
const processTitle = command ? [command, ...args].join(" ") : null;
let initialTitle = lockedTitle;
let initialTitle = initialManualTitle;
if (!initialTitle && processTitle) {
initialTitle = humanizeProcessTitle(processTitle) ?? normalizeProcessTitle(processTitle);
}
emitTitleChange(initialTitle);
// Respond to DA1 queries (CSI c or CSI 0 c) — apps like nvim query terminal capabilities
terminal.parser.registerCsiHandler({ final: "c" }, (params) => {
if (params.length === 0 || (params.length === 1 && params[0] === 0)) {
ptyProcess.write("\x1b[?62;4;22c");
@@ -637,9 +666,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
return true;
});
let disposeTitleChangeSubscription: { dispose(): void } | null = null;
if (!lockedTitle) {
disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => {
if (titleMode === "auto") {
titleChangeSubscription = terminal.onTitleChange((nextTitle) => {
if (disposed || killed) {
return;
}
@@ -650,6 +678,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
titleDebounceTimer = setTimeout(() => {
titleDebounceTimer = null;
emitTitleChange(pendingTitle);
pendingTitle = undefined;
}, TERMINAL_TITLE_DEBOUNCE_MS);
});
}
@@ -712,11 +741,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
clearImmediate(inputFlushImmediate);
inputFlushImmediate = null;
}
if (titleDebounceTimer) {
clearTimeout(titleDebounceTimer);
titleDebounceTimer = null;
}
disposeTitleChangeSubscription?.dispose();
clearPendingTitleChange();
disposeTitleChangeSubscription();
disposeCommandLifecycleSubscription.dispose();
terminal.dispose();
listeners.clear();
@@ -1049,6 +1075,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
getStateSnapshot,
getReplayPreamble,
getTitle,
setTitle,
getExitInfo,
kill,
killAndWait,
@@ -256,6 +256,16 @@ export function createWorkerTerminalManager(
getTitle(): string | undefined {
return record.info.title;
},
setTitle(nextTitle: string): void {
const manualTitle = nextTitle.trim();
if (!manualTitle) {
return;
}
record.info = { ...record.info, title: manualTitle };
for (const listener of Array.from(record.titleChangeListeners)) {
listener(manualTitle);
}
},
getExitInfo(): TerminalExitInfo | null {
return record.exitInfo;
},
@@ -536,6 +546,15 @@ export function createWorkerTerminalManager(
})) as TerminalWorkerStateResult;
},
setTerminalTitle(id: string, title: string): boolean {
const session = recordsById.get(id)?.session;
if (!session) {
return false;
}
session.setTitle(title);
return true;
},
killTerminal(id: string): void {
void sendRequest({ type: "killTerminal", terminalId: id }).catch(() => {
// no-op; kill is intentionally best-effort and synchronous in the public interface.
@@ -0,0 +1,66 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import { slugify, validateBranchSlug } from "./branch-slug.js";
describe("branch slug utilities", () => {
it("normalizes display names to lowercase branch slugs", () => {
expect(slugify("My Feature")).toBe("my-feature");
});
it("collapses punctuation and whitespace to a single hyphen", () => {
expect(slugify("My___Feature! @#$ Next")).toBe("my-feature-next");
});
it("trims leading and trailing hyphens", () => {
expect(slugify(" --- My Feature !!! ")).toBe("my-feature");
});
it("enforces the 50 character slug limit", () => {
const slug = slugify("a".repeat(60));
expect(slug).toBe("a".repeat(50));
});
it("validates branch slugs with clear messages", () => {
expect(validateBranchSlug("my-feature")).toEqual({ valid: true });
expect(validateBranchSlug("")).toEqual({
valid: false,
error: "Branch name cannot be empty",
});
expect(validateBranchSlug("My Feature")).toEqual({
valid: false,
error:
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
});
expect(validateBranchSlug("-my-feature")).toEqual({
valid: false,
error: "Branch name cannot start or end with a hyphen",
});
});
it("is exported through the package subpath", () => {
const currentDir = dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(
readFileSync(join(currentDir, "..", "..", "package.json"), "utf8"),
) as {
exports?: Record<string, { types?: string; source?: string; default?: string }>;
};
expect(packageJson.exports?.["./utils/branch-slug"]).toEqual({
types: "./dist/server/utils/branch-slug.d.ts",
source: "./src/utils/branch-slug.ts",
default: "./dist/server/utils/branch-slug.js",
});
});
it("does not import server-only modules", () => {
const currentDir = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(join(currentDir, "branch-slug.ts"), "utf8");
expect(source).not.toMatch(
/from\s+["'](?:node:)?(?:fs|path|child_process)["']|from\s+["']node:/,
);
});
});
+61
View File
@@ -0,0 +1,61 @@
/**
* Validate that a string is a valid git branch name slug.
* Must be lowercase alphanumeric with hyphens and forward slashes only.
*/
export function validateBranchSlug(slug: string): {
valid: boolean;
error?: string;
} {
if (!slug || slug.length === 0) {
return { valid: false, error: "Branch name cannot be empty" };
}
if (slug.length > 100) {
return { valid: false, error: "Branch name too long (max 100 characters)" };
}
const validPattern = /^[a-z0-9-/]+$/;
if (!validPattern.test(slug)) {
return {
valid: false,
error:
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
};
}
if (slug.startsWith("-") || slug.endsWith("-")) {
return {
valid: false,
error: "Branch name cannot start or end with a hyphen",
};
}
if (slug.includes("--")) {
return { valid: false, error: "Branch name cannot have consecutive hyphens" };
}
return { valid: true };
}
export const MAX_SLUG_LENGTH = 50;
/**
* Convert a string to kebab-case for branch names.
*/
export function slugify(input: string): string {
const slug = input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (slug.length <= MAX_SLUG_LENGTH) {
return slug;
}
const truncated = slug.slice(0, MAX_SLUG_LENGTH);
const lastHyphen = truncated.lastIndexOf("-");
if (lastHyphen > MAX_SLUG_LENGTH / 2) {
return truncated.slice(0, lastHyphen);
}
return truncated.replace(/-+$/, "");
}
@@ -16,8 +16,12 @@ vi.mock("child_process", async () => {
const [command, commandArgs] = args;
if (command === "git" && Array.isArray(commandArgs)) {
const normalizedArgs = commandArgs.map((arg) => String(arg));
// `runGitCommand` always prepends `-c core.quotepath=false`; skip it to
// find the actual git subcommand.
const subcommandIndex =
normalizedArgs[0] === "-c" && normalizedArgs[1] === "core.quotepath=false" ? 2 : 0;
const isTrackedTextDiff =
normalizedArgs[0] === "diff" &&
normalizedArgs[subcommandIndex] === "diff" &&
normalizedArgs.includes("HEAD") &&
!normalizedArgs.includes("--numstat") &&
!normalizedArgs.includes("--no-index") &&
+44 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { execFileSync } from "child_process";
import { execFileSync, execSync } from "child_process";
import {
existsSync,
mkdtempSync,
@@ -34,6 +34,7 @@ import {
resolveBranchCheckout,
resolveRepositoryDefaultBranch,
parseWorktreeList,
renameCurrentBranch,
isPaseoWorktreePath,
isDescendantPath,
warmCheckoutShortstatInBackground,
@@ -263,6 +264,48 @@ describe("checkout git utilities", () => {
expect(branch).toBe("feature/rebase-test");
});
it("renames the checked out branch and returns concrete branch names", async () => {
execSync("git checkout -b feature/old-name", { cwd: repoDir });
const result = await renameCurrentBranch(repoDir, "feature/new-name");
const currentBranch = execSync("git branch --show-current", { cwd: repoDir }).toString().trim();
expect(currentBranch).toBe("feature/new-name");
expect(result).toEqual({
previousBranch: "feature/old-name",
currentBranch: "feature/new-name",
});
expect(() =>
execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir }),
).toThrow();
expect(
execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir })
.toString()
.trim(),
).toContain("refs/heads/feature/new-name");
});
it("fails when renaming the checked out branch to an existing branch", async () => {
execSync("git branch feature/new-name", { cwd: repoDir });
execSync("git checkout -b feature/old-name", { cwd: repoDir });
await expect(renameCurrentBranch(repoDir, "feature/new-name")).rejects.toThrow();
expect(execSync("git branch --show-current", { cwd: repoDir }).toString().trim()).toBe(
"feature/old-name",
);
expect(
execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir })
.toString()
.trim(),
).toContain("refs/heads/feature/old-name");
expect(
execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir })
.toString()
.trim(),
).toContain("refs/heads/feature/new-name");
});
it("handles status/diff/commit in a normal repo", async () => {
writeFileSync(join(repoDir, "file.txt"), "updated\n");
@@ -32,8 +32,8 @@ export type WorkspaceMatchMode = "fuzzy" | "suffix";
const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 100;
const DEFAULT_MAX_DEPTH = 6;
const DEFAULT_MAX_DIRECTORIES_SCANNED = 5000;
const DEFAULT_MAX_DEPTH = 12;
const DEFAULT_MAX_DIRECTORIES_SCANNED = 20000;
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000;
+3 -1
View File
@@ -77,7 +77,9 @@ export function runGitCommand(
logger.trace(traceContext, "Spawning git command");
}
const child = spawnProcess("git", args, {
// `core.quotepath=false` makes git emit raw UTF-8 paths instead of
// octal-escaping non-ASCII bytes (e.g. `测试文件.txt` vs `"\346\265\213..."`).
const child = spawnProcess("git", ["-c", "core.quotepath=false", ...args], {
cwd: options.cwd,
envOverlay,
shell: false,
+3 -66
View File
@@ -30,6 +30,9 @@ import { spawnProcess } from "./spawn.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { validateBranchSlug } from "./branch-slug.js";
export { slugify, validateBranchSlug } from "./branch-slug.js";
const execFileAsync = promisify(execFile);
const READ_ONLY_GIT_ENV = {
@@ -740,72 +743,6 @@ export async function getGitCommonDir(cwd: string): Promise<string> {
return commonDir;
}
/**
* Validate that a string is a valid git branch name slug
* Must be lowercase, alphanumeric, hyphens only
*/
export function validateBranchSlug(slug: string): {
valid: boolean;
error?: string;
} {
if (!slug || slug.length === 0) {
return { valid: false, error: "Branch name cannot be empty" };
}
if (slug.length > 100) {
return { valid: false, error: "Branch name too long (max 100 characters)" };
}
// Check for valid characters: lowercase letters, numbers, hyphens, forward slashes
const validPattern = /^[a-z0-9-/]+$/;
if (!validPattern.test(slug)) {
return {
valid: false,
error:
"Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes",
};
}
// Cannot start or end with hyphen
if (slug.startsWith("-") || slug.endsWith("-")) {
return {
valid: false,
error: "Branch name cannot start or end with a hyphen",
};
}
// Cannot have consecutive hyphens
if (slug.includes("--")) {
return { valid: false, error: "Branch name cannot have consecutive hyphens" };
}
return { valid: true };
}
const MAX_SLUG_LENGTH = 50;
/**
* Convert string to kebab-case for branch names
*/
export function slugify(input: string): string {
const slug = input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
if (slug.length <= MAX_SLUG_LENGTH) {
return slug;
}
// Truncate at word boundary (hyphen) if possible
const truncated = slug.slice(0, MAX_SLUG_LENGTH);
const lastHyphen = truncated.lastIndexOf("-");
if (lastHyphen > MAX_SLUG_LENGTH / 2) {
return truncated.slice(0, lastHyphen);
}
return truncated.replace(/-+$/, "");
}
const WORKTREE_PROJECT_HASH_LENGTH = 8;
function deriveShortAlphanumericHash(value: string): string {