Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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") &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user