mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f5224122a | ||
|
|
af27e28054 |
@@ -12,10 +12,7 @@ import {
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import { WorkspaceDirectoryReplica } from "./workspace-replica";
|
||||
|
||||
function workspace(
|
||||
id: string,
|
||||
projectId = "project"
|
||||
): WorkspaceDescriptorPayload {
|
||||
function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload {
|
||||
return {
|
||||
id,
|
||||
projectId,
|
||||
@@ -50,16 +47,11 @@ it("commits workspace and project-parent state with filtered removals", () => {
|
||||
{
|
||||
workspaces: new Map([
|
||||
["kept", normalizeWorkspaceDescriptor(workspace("kept"))],
|
||||
[
|
||||
"filtered",
|
||||
normalizeWorkspaceDescriptor(
|
||||
workspace("filtered", "filtered-project")
|
||||
),
|
||||
],
|
||||
["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))],
|
||||
]),
|
||||
projects: new Map([[empty.projectId, empty]]),
|
||||
},
|
||||
[{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }]
|
||||
[{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }],
|
||||
);
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
@@ -73,16 +65,10 @@ it("commits the authoritative snapshot before buffered project updates", () => {
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
const attachedMain = normalizeWorkspaceDescriptor(
|
||||
workspace("attached-main", "attached")
|
||||
);
|
||||
const attachedFeature = normalizeWorkspaceDescriptor(
|
||||
workspace("attached-feature", "attached")
|
||||
);
|
||||
const attachedMain = normalizeWorkspaceDescriptor(workspace("attached-main", "attached"));
|
||||
const attachedFeature = normalizeWorkspaceDescriptor(workspace("attached-feature", "attached"));
|
||||
const removed = normalizeWorkspaceDescriptor(workspace("removed", "removed"));
|
||||
const unrelated = normalizeWorkspaceDescriptor(
|
||||
workspace("unrelated", "unrelated")
|
||||
);
|
||||
const unrelated = normalizeWorkspaceDescriptor(workspace("unrelated", "unrelated"));
|
||||
const staleAttachedProject = normalizeProjectDescriptor({
|
||||
projectId: "attached",
|
||||
projectDisplayName: "Stale attached project",
|
||||
@@ -138,7 +124,7 @@ it("commits the authoritative snapshot before buffered project updates", () => {
|
||||
},
|
||||
},
|
||||
{ kind: "remove", projectId: "removed" },
|
||||
]
|
||||
],
|
||||
);
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
@@ -192,11 +178,9 @@ it("preserves unchanged project identity when another project changes", () => {
|
||||
["second", second],
|
||||
]),
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
const previousSecond = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.projects.get("second");
|
||||
const previousSecond = useSessionStore.getState().sessions[serverId]?.projects.get("second");
|
||||
|
||||
replica.commitSnapshot(
|
||||
{
|
||||
@@ -206,12 +190,12 @@ it("preserves unchanged project identity when another project changes", () => {
|
||||
["second", { ...second }],
|
||||
]),
|
||||
},
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.projects.get("second")
|
||||
).toBe(previousSecond);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.projects.get("second")).toBe(
|
||||
previousSecond,
|
||||
);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
@@ -221,10 +205,7 @@ it("does not invent a null-key project from a workspace update", () => {
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
|
||||
replica.applyDelta({
|
||||
kind: "upsert",
|
||||
workspace: workspace("main", "fresh-project"),
|
||||
});
|
||||
replica.applyDelta({ kind: "upsert", workspace: workspace("main", "fresh-project") });
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
expect(session?.workspaces.has("main")).toBe(true);
|
||||
@@ -241,40 +222,11 @@ it("does not restore a targeted cached workspace while its archive is pending",
|
||||
markWorkspaceArchivePending({ serverId, workspaceId });
|
||||
|
||||
try {
|
||||
replica.commitCachedWorkspace(
|
||||
normalizeWorkspaceDescriptor(workspace(workspaceId)),
|
||||
undefined
|
||||
);
|
||||
replica.commitCachedWorkspace(normalizeWorkspaceDescriptor(workspace(workspaceId)), undefined);
|
||||
|
||||
expect(
|
||||
useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)
|
||||
).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
|
||||
} finally {
|
||||
clearWorkspaceArchivePending({ serverId, workspaceId });
|
||||
store.clearSession(serverId);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a live pin when the older workspace creation response arrives after it (#5447)", () => {
|
||||
const serverId = "creation-response-after-pin";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
const created = workspace("created");
|
||||
const pinnedAt = "2026-09-26T12:29:32.206Z";
|
||||
|
||||
replica.applyDelta({ kind: "upsert", workspace: created });
|
||||
replica.applyDelta({ kind: "upsert", workspace: { ...created, pinnedAt } });
|
||||
replica.acceptWorkspaces([
|
||||
{
|
||||
...normalizeWorkspaceDescriptor({ ...created, pinnedAt: null }),
|
||||
status: "running",
|
||||
},
|
||||
]);
|
||||
|
||||
const row = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.workspaces.get("created");
|
||||
expect(row?.pinnedAt).toBe(pinnedAt);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
@@ -242,6 +242,54 @@ describe("Codex active-turn steering admission", () => {
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a steer resolves a Codex skill mentioned after prose into app-server skill input", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"skills/list": () => ({
|
||||
data: [
|
||||
{
|
||||
cwd: "/workspace/project",
|
||||
skills: [
|
||||
{
|
||||
name: "paseo-implement",
|
||||
description: "Execute an existing Paseo plan.",
|
||||
path: "/workspace/skills/paseo-implement/SKILL.md",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
"turn/steer": () => ({ turn: { id: "native-A" } }),
|
||||
});
|
||||
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
|
||||
|
||||
await expect(
|
||||
session.steerActiveTurn!("Then use /paseo-implement to complete it.", {
|
||||
expectedTurnId: paseoTurnId,
|
||||
}),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
|
||||
const steer = appServer.requests().find((request) => request.method === "turn/steer");
|
||||
expect(steer?.params).toEqual(
|
||||
expect.objectContaining({
|
||||
input: [
|
||||
{
|
||||
type: "skill",
|
||||
name: "paseo-implement",
|
||||
path: "/workspace/skills/paseo-implement/SKILL.md",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Then use $paseo-implement to complete it.",
|
||||
text_elements: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await session.close();
|
||||
appServer.assertNoErrors();
|
||||
});
|
||||
|
||||
test("a clearing steer denies every pending permission through its provider handler", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"turn/steer": () => ({ turn: { id: "native-A" } }),
|
||||
@@ -2233,6 +2281,59 @@ describe("Codex app-server provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("resolves a Codex skill mentioned after prose into app-server skill input", async () => {
|
||||
const session = createSession();
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === "skills/list") {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
cwd: "/tmp/codex-question-test",
|
||||
skills: [
|
||||
{
|
||||
name: "paseo-implement",
|
||||
description: "Execute an existing Paseo plan.",
|
||||
path: "/tmp/skills/paseo-implement/SKILL.md",
|
||||
},
|
||||
],
|
||||
errors: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === "thread/loaded/list") {
|
||||
return { data: ["test-thread"] };
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`Unexpected request: ${method}`);
|
||||
});
|
||||
|
||||
session.activeForegroundTurnId = null;
|
||||
session.client = createStub<CodexClientLike>({ request });
|
||||
|
||||
await session.startTurn("Review the accepted plan, then use /paseo-implement to complete it.");
|
||||
|
||||
const turnStartCall = request.mock.calls.find(([method]) => method === "turn/start");
|
||||
expect(turnStartCall?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
input: [
|
||||
{
|
||||
type: "skill",
|
||||
name: "paseo-implement",
|
||||
path: "/tmp/skills/paseo-implement/SKILL.md",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Review the accepted plan, then use $paseo-implement to complete it.",
|
||||
text_elements: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("lists project skill commands when app-server receives the project cwd in cwds", async () => {
|
||||
const commands = await listCommandsFromFakeCodex([
|
||||
{
|
||||
|
||||
@@ -3169,6 +3169,26 @@ function enabledCodexSkills(
|
||||
return Array.from(skillsByName.values());
|
||||
}
|
||||
|
||||
// Composer autocomplete inserts skills as `/name` anywhere in the prompt, while
|
||||
// Codex only injects a skill it receives as a skill input item.
|
||||
const SKILL_MENTION_PATTERN = /(^|\s)\/([^\s/"']+)/g;
|
||||
|
||||
function resolveCodexSkillMentions(
|
||||
text: string,
|
||||
skills: ReadonlyArray<{ name: string; path: string }>,
|
||||
): { skills: CodexSkillPromptBlock[]; text: string } {
|
||||
const mentioned = new Map<string, CodexSkillPromptBlock>();
|
||||
const rewritten = text.replace(SKILL_MENTION_PATTERN, (mention, prefix: string, name: string) => {
|
||||
const skill = skills.find((entry) => entry.name === name);
|
||||
if (!skill) {
|
||||
return mention;
|
||||
}
|
||||
mentioned.set(skill.name, { type: "skill", name: skill.name, path: skill.path });
|
||||
return `${prefix}$${skill.name}`;
|
||||
});
|
||||
return { skills: Array.from(mentioned.values()), text: rewritten };
|
||||
}
|
||||
|
||||
type CodexPromptContentBlock = AgentPromptContentBlock | CodexSkillPromptBlock;
|
||||
type CodexPromptInput = string | CodexPromptContentBlock[];
|
||||
interface CodexTextElement {
|
||||
@@ -4063,6 +4083,18 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return args ? `$${commandName} ${args}` : `$${commandName}`;
|
||||
}
|
||||
|
||||
private async attachMentionedSkills(prompt: AgentPromptInput): Promise<CodexPromptInput> {
|
||||
if (typeof prompt !== "string" || !prompt.includes("/")) {
|
||||
return prompt;
|
||||
}
|
||||
await this.loadSkills();
|
||||
const resolved = resolveCodexSkillMentions(prompt, this.cachedSkills ?? []);
|
||||
if (resolved.skills.length === 0) {
|
||||
return prompt;
|
||||
}
|
||||
return [...resolved.skills, { type: "text", text: resolved.text }];
|
||||
}
|
||||
|
||||
private async buildTurnStartParams(
|
||||
prompt: CodexPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
@@ -4266,7 +4298,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||
const effectivePrompt = slashCommand
|
||||
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
||||
: prompt;
|
||||
: await this.attachMentionedSkills(prompt);
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
@@ -4333,7 +4365,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const input = await this.buildUserInput(prompt);
|
||||
const input = await this.buildUserInput(await this.attachMentionedSkills(prompt));
|
||||
if (!this.matchesSteerAdmission({ client, threadId, nativeTurnId, foregroundTurnId })) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { DaemonClient } from "./test-utils/index.js";
|
||||
import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js";
|
||||
|
||||
// #5447: a second client pins a workspace as soon as it sees it appear, while
|
||||
// the creating client is still waiting for its initial agent.
|
||||
test("workspace.create response reflects a pin made while the initial agent was starting", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const directory = mkdtempSync(path.join(tmpdir(), "workspace-pin-order-"));
|
||||
const creator = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.9.2",
|
||||
});
|
||||
const pinner = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.9.2",
|
||||
});
|
||||
const seen: Array<{ source: string; pinnedAt: string | null | undefined }> =
|
||||
[];
|
||||
try {
|
||||
await creator.connect();
|
||||
await pinner.connect();
|
||||
await creator.fetchWorkspaces({ subscribe: {} });
|
||||
await pinner.fetchWorkspaces({ subscribe: {} });
|
||||
const pinned = new Set<string>();
|
||||
pinner.subscribeRawMessages((message) => {
|
||||
if (
|
||||
message.type !== "workspace_update" ||
|
||||
message.payload.kind !== "upsert"
|
||||
)
|
||||
return;
|
||||
const id = message.payload.workspace.id;
|
||||
if (pinned.has(id)) return;
|
||||
pinned.add(id);
|
||||
void pinner.setWorkspacePinned(id, true);
|
||||
});
|
||||
creator.subscribeRawMessages((message) => {
|
||||
if (
|
||||
message.type === "workspace_update" &&
|
||||
message.payload.kind === "upsert"
|
||||
) {
|
||||
seen.push({
|
||||
source: "workspace_update",
|
||||
pinnedAt: message.payload.workspace.pinnedAt,
|
||||
});
|
||||
}
|
||||
if (message.type === "workspace.create.response") {
|
||||
seen.push({
|
||||
source: "create_response",
|
||||
pinnedAt: message.payload.workspace?.pinnedAt,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const created = await creator.createWorkspace({
|
||||
source: { kind: "directory", path: directory },
|
||||
agent: { provider: "claude", cwd: directory, modeId: "default" },
|
||||
});
|
||||
expect(created.error).toBeNull();
|
||||
|
||||
const liveBeforeResponse = seen
|
||||
.slice(
|
||||
0,
|
||||
seen.findIndex((entry) => entry.source === "create_response")
|
||||
)
|
||||
.filter((entry) => entry.source === "workspace_update")
|
||||
.at(-1);
|
||||
expect(liveBeforeResponse?.pinnedAt).toBeTruthy();
|
||||
expect(created.workspace?.pinnedAt).toBe(liveBeforeResponse?.pinnedAt);
|
||||
} finally {
|
||||
await creator.close().catch(() => undefined);
|
||||
await pinner.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}, 180000);
|
||||
Reference in New Issue
Block a user