Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 0849dec44e test(opencode): reproduce v2 discovery failure and isolate the decisive factor
Ablates the two changes opencode v2 made to its server contract against the
real provider path: the mandatory server password and the /api route move.
The password fails first and produces the reported error; the route move is
masked behind it. Includes the red repro for #4878.

Refs #4878
2026-09-22 19:45:54 +02:00
Mohamed BoudraandBag Raul Cristian c8a7667a03 fix(app): keep conversations in the sidebar across a sequenced catch-up (#5189)
When the client reconnects with a live sync cursor, the daemon answers
with only the agents that changed, and the replica rebuilds the merged
directory from what it already holds. That rebuild went through
projectAgentDirectoryEntry, which returned null for any agent without a
projectPlacement, so those members never reached the merged map,
commitSnapshot treated them as absent, and removeFromDirectory deleted
them from the store.

An agent hydrated from its timeline or restored from the replica cache
has no placement: neither normalizeAgentSnapshot nor deserializeAgent
sets one. Every other path in the same flow derives a placement from the
working directory instead of dropping the agent, so the projection does
the same now via resolveProjectPlacement, and the function is total.

The conversations were always intact on the daemon; only the client lost
them, which is why a full restart brought them back until the next
reconnect drained the sidebar again.

Co-authored-by: Bag Raul Cristian <17720135+bagutzu@users.noreply.github.com>
2026-09-22 18:56:05 +02:00
4c051388e1 Reach every Codex conversation in Import session (#5174)
* fix(codex): page through Codex threads when listing importable sessions

Codex answers thread/list with at most 100 rows per response whatever
limit is requested, and returns a cursor for the rest. The adapter read
the first response and returned, so Import session could only ever offer
the newest 100 Codex threads: on a store of 456 threads, a host-wide
search that asks for a 500-thread scan saw 100.

Follow nextCursor until the caller's window is full, and ask Codex to
page by last use so the scanned window matches how the import sheet ranks
candidates. Unknown keys are ignored by Codex, so older builds keep their
creation-time order.

Closes #5073

Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>

* fix(codex): end the thread scan on a page that carries no threads

The previous guard only caught a cursor that repeated immediately, so a
Codex cursor cycle across two or more pages (A to B to A) kept paging
for ever behind a caller that had already timed out.

Every page that continues the scan now has to carry rows, which bounds
the loop by the requested window whatever cursors Codex hands back.

* fix(codex): keep a thread once when the scan sees it on two pages

Paging by last use means a thread touched while the scan is running
sorts to the front and comes back on a later page, so the same
conversation could be offered twice and displace another one inside the
window.

Identify each thread as it arrives and keep it once. A page that brings
nothing new now ends the scan, which also covers a cursor that cycles
while still serving rows.

---------

Co-authored-by: paseo-bot <paseo-bot@users.noreply.github.com>
Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com>
2026-09-22 17:20:31 +02:00
paseo-ai[bot] 91d9cf1dbd fix: update lockfile signatures and Nix hash [skip ci] 2026-09-22 10:36:31 +00:00
7 changed files with 461 additions and 15 deletions
+1 -1
View File
@@ -1 +1 @@
sha256-6du3a0DzuvF+m2cPTOXDhdZZScQPkqicikdDxWIKZ5o=
sha256-3cqO8DpPVDjYaSh0q5EPJuMpCgl2OJ17uLGVGuk1Wwc=
@@ -369,4 +369,25 @@ describe("AgentDirectoryReplica", () => {
).toEqual(directoryPlacement);
store.clearSession(serverId);
});
it("keeps a timeline-hydrated agent when a sequenced catch-up carries no entries", () => {
const serverId = "agent-replica-timeline-membership";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new AgentDirectoryReplica(
serverId,
() => undefined,
() => undefined,
);
// A conversation opened from history arrives over its timeline, without the
// project placement the directory carries, and is a member from then on.
replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("timeline only"));
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
replica.commitChanges([], [], []);
expect(useSessionStore.getState().sessions[serverId]?.agents.has("agent")).toBe(true);
store.clearSession(serverId);
});
});
@@ -6,15 +6,21 @@ import type { Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
import { resolveProjectPlacement } from "@/utils/project-placement";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
import { AgentStoreProjection } from "./internal/agent-store";
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null {
return agent.projectPlacement
? { agent: projectAgentSnapshot(agent), project: agent.projectPlacement }
: null;
// A member hydrated from its timeline or restored from the replica cache carries
// no placement. Every other path in this flow derives one from the working
// directory, so this one does too; returning nothing here would drop the member
// from the merged directory and delete the conversation on the next catch-up.
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry {
return {
agent: projectAgentSnapshot(agent),
project: resolveProjectPlacement({ projectPlacement: agent.projectPlacement, cwd: agent.cwd }),
};
}
export interface AgentLifecycleToken {
@@ -164,8 +170,7 @@ export class AgentDirectoryReplica {
const previous = this.storeProjection.snapshot();
const merged = new Map<string, FetchAgentsEntry>();
for (const agent of previous.values()) {
const entry = projectAgentDirectoryEntry(agent);
if (entry) merged.set(agent.id, entry);
merged.set(agent.id, projectAgentDirectoryEntry(agent));
}
for (const entry of entries) merged.set(entry.agent.id, entry);
const removalsAsDeltas: AgentDirectoryDelta[] = removals.map(({ id }) => ({
@@ -640,6 +640,63 @@ describe("DirectorySync session readiness", () => {
directory.dispose();
});
it("reconciles agent changes on top of the accepted cached baseline", async () => {
const serverId = "cached-agent-changes";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const cachedAgent = createAgent(serverId, "cached-agent");
const releaseNetwork = client.holdAgentFetch();
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readAgent: async () => undefined,
readWorkspace: async () => undefined,
readDirectory: async () => ({
agents: new Map([[cachedAgent.id, cachedAgent]]),
workspaces: new Map(),
projects: new Map(),
checkpoint: { agents: { generation: "g", afterSeq: 7 } },
}),
commitDirectoryMutations: () => undefined,
},
);
directory.connectionChanged({
client: client as unknown as DaemonClient,
status: "online",
source: { clientGeneration: 1, connectionEpoch: 1 },
});
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, directorySync: true },
});
const refresh = directory.refreshAgents();
await expect.poll(() => client.fetchAgentsCalls).toBe(1);
releaseNetwork({
requestId: "agents",
entries: [],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] },
});
await refresh;
expect(client.lastAgentOptions).toMatchObject({
sync: { generation: "g", afterSeq: 7 },
});
expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(true);
directory.dispose();
});
it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => {
const serverId = "late-directory-cache";
serverIds.add(serverId);
@@ -5991,6 +5991,138 @@ describe("Codex app-server provider", () => {
});
describe("Codex importable sessions", () => {
const CODEX_THREAD_PAGE_CAP = 100;
// Codex answers thread/list with at most 100 rows per response whatever limit
// the caller asks for, and hands back a cursor for the remaining threads.
function cappedThreadListHandler(threads: Array<Record<string, unknown>>) {
return (input: unknown) => {
const params = (input ?? {}) as {
limit?: number;
cursor?: string;
cwd?: string;
sortKey?: string;
};
const recencyKey = params.sortKey === "updated_at" ? "updatedAt" : "createdAt";
const matching = threads
.filter((thread) => !params.cwd || thread.cwd === params.cwd)
.sort((a, b) => Number(b[recencyKey]) - Number(a[recencyKey]));
const offset = Number(params.cursor ?? 0);
const page = matching.slice(
offset,
offset + Math.min(params.limit ?? CODEX_THREAD_PAGE_CAP, CODEX_THREAD_PAGE_CAP),
);
const nextOffset = offset + page.length;
return {
data: page,
nextCursor: nextOffset < matching.length ? String(nextOffset) : null,
};
};
}
function threadsUsedInReverseCreationOrder(count: number) {
// The most recently used thread is also the oldest one created, so a scan
// that follows Codex's creation-time page order misses it.
return Array.from({ length: count }, (_, index) => ({
id: `thread-${index}`,
cwd: "/workspace/project-a",
preview: `Session ${index}`,
createdAt: index,
updatedAt: count - index,
}));
}
test("fills the requested window across the pages Codex caps at 100 rows", async () => {
const threads = threadsUsedInReverseCreationOrder(250);
const appServer = createFakeCodexAppServer({
"thread/list": cappedThreadListHandler([
...threads,
{ id: "other-project", cwd: "/workspace/project-b", createdAt: 9000, updatedAt: 9000 },
]),
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({
limit: 240,
cwd: "/workspace/project-a",
});
expect(sessions.map((session) => session.providerHandleId)).toEqual(
threads.slice(0, 240).map((thread) => thread.id),
);
appServer.assertNoErrors();
});
test("keeps the scanned window on the most recently used threads", async () => {
const threads = threadsUsedInReverseCreationOrder(550);
const appServer = createFakeCodexAppServer({
"thread/list": cappedThreadListHandler(threads),
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 700 });
expect(sessions.map((session) => session.providerHandleId)).toEqual(
threads.slice(0, 500).map((thread) => thread.id),
);
appServer.assertNoErrors();
});
test("keeps a thread once when it moves onto a later page mid-scan", async () => {
// A thread used while the scan is paging sorts to the front and comes back
// on the next page.
const appServer = createFakeCodexAppServer({
"thread/list": (input) => {
const { cursor } = (input ?? {}) as { cursor?: string };
return cursor
? {
data: [
{ id: "thread-1", cwd: "/workspace/project-a", updatedAt: 9000 },
{ id: "thread-3", cwd: "/workspace/project-a", updatedAt: 1000 },
],
nextCursor: null,
}
: {
data: [
{ id: "thread-1", cwd: "/workspace/project-a", updatedAt: 3000 },
{ id: "thread-2", cwd: "/workspace/project-a", updatedAt: 2000 },
],
nextCursor: "page-2",
};
},
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 });
expect(sessions.map((session) => session.providerHandleId)).toEqual([
"thread-1",
"thread-2",
"thread-3",
]);
appServer.assertNoErrors();
});
test("stops scanning when Codex pages without handing back a new thread", async () => {
const appServer = createFakeCodexAppServer({
// A cursor cycle: the two pages after the first serve threads the scan
// already holds and point at each other, so it can never fill its window.
"thread/list": (input) => {
const { cursor } = (input ?? {}) as { cursor?: string };
const thread = { id: "thread-1", cwd: "/workspace/project-a" };
if (cursor === "page-a") return { data: [thread], nextCursor: "page-b" };
if (cursor === "page-b") return { data: [thread], nextCursor: "page-a" };
return { data: [thread], nextCursor: "page-a" };
},
});
const provider = createProviderWithFakeAppServer(appServer);
const sessions = await provider.listImportableSessions({ limit: 500, scanLimit: 500 });
expect(sessions.map((session) => session.providerHandleId)).toEqual(["thread-1"]);
appServer.assertNoErrors();
});
test("listImportableSessions uses thread list metadata without hydrating thread history", async () => {
const allThreads = [
{
@@ -6071,7 +6203,10 @@ describe("Codex importable sessions", () => {
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
},
},
{ method: "thread/list", params: { limit: 50, cwd: "/workspace/project-a" } },
{
method: "thread/list",
params: { limit: 50, sortKey: "updated_at", cwd: "/workspace/project-a" },
},
]);
});
});
@@ -914,6 +914,60 @@ const CodexModelListResponseSchema = z.object({
.optional(),
});
/**
* Read the newest `window.limit` threads Codex knows about.
*
* Codex caps every `thread/list` response at 100 rows whatever limit it is
* given (codex-cli 0.153 and 0.155), so one request only ever sees the newest
* page and hands back a cursor for the rest. Follow that cursor until the
* caller's window is full or Codex runs out of threads.
*/
async function readCodexThreadWindow(
client: Pick<CodexAppServerClientLike, "request">,
logger: Logger,
window: { limit: number; cwd?: string },
): Promise<Array<Record<string, unknown>>> {
// A thread updated while the scan is paging moves under `updated_at` order
// and can come back on a later page, so identify each one and keep it once.
// A row Codex sends without an id stands for itself.
const threads = new Map<unknown, Record<string, unknown>>();
let cursor: string | undefined;
while (threads.size < window.limit) {
const response = toObjectRecord(
await client.request("thread/list", {
limit: window.limit - threads.size,
// Rank the window by last use. Codex pages by creation time by default,
// which drops an old conversation that is still being worked in.
// Older Codex builds ignore the unknown key and keep that order.
sortKey: "updated_at",
...(window.cwd ? { cwd: window.cwd } : {}),
...(cursor ? { cursor } : {}),
}),
);
const page = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const sizeBeforePage = threads.size;
for (const thread of page) {
const identity = typeof thread.id === "string" ? thread.id : thread;
if (!threads.has(identity)) threads.set(identity, thread);
}
const nextCursor = typeof response?.nextCursor === "string" ? response.nextCursor : undefined;
if (!nextCursor) break;
if (threads.size === sizeBeforePage) {
// Every page that continues the scan brings a thread the scan has not
// seen, so the loop is bounded by the window. A page that brings none
// would let a Codex-side cursor bug (a stuck cursor, or a cycle) page for
// ever behind a caller that has already timed out.
logger.warn(
{ cursor: nextCursor },
"codex thread/list returned no new threads mid-scan, stopping the session scan",
);
break;
}
cursor = nextCursor;
}
return [...threads.values()];
}
function filterCodexThreadsByCwd(
threads: Array<Record<string, unknown>>,
cwd: string | undefined,
@@ -7143,13 +7197,10 @@ export class CodexAppServerAgentClient implements AgentClient {
// filtering since most threads will be from other cwds, then keep the
// local realpath-aware filter for symlink-equivalent workspace paths.
const listLimit = options?.cwd ? Math.max(scanLimit, 50) : scanLimit;
const response = toObjectRecord(
await client.request("thread/list", {
limit: listLimit,
...(options?.cwd ? { cwd: options.cwd } : {}),
}),
);
const allThreads = Array.isArray(response?.data) ? response.data.filter(isRecord) : [];
const allThreads = await readCodexThreadWindow(client, this.logger, {
limit: listLimit,
cwd: options?.cwd,
});
const threads = filterCodexThreadsByCwd(allThreads, options?.cwd);
return threads.slice(0, limit).map((thread) => {
const threadId = typeof thread.id === "string" ? thread.id : "";
@@ -0,0 +1,177 @@
import type { ChildProcess } from "node:child_process";
import { EventEmitter } from "node:events";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { randomBytes } from "node:crypto";
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { OpenCodeEventConsumer } from "./opencode/event-consumer.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
/**
* Upstream modelled on opencode v2.0.3 as read from its own source:
* - packages/cli/src/server-process.ts always has a password (env or random)
* - packages/protocol/src/groups/provider.ts serves provider.list at /api/provider
* - packages/cli/src/services/web-ui.ts serves index.html for any unmatched route
*/
async function createUpstream(options: { readonly auth: boolean; readonly apiPrefix: boolean }) {
const password = randomBytes(32).toString("base64url");
const providerPayload = {
connected: ["synthetic"],
all: [
{
id: "synthetic",
name: "Synthetic",
source: "api",
models: { "hf:zai-org/GLM-4.6": { id: "hf:zai-org/GLM-4.6", name: "GLM 4.6" } },
},
],
};
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
if (options.auth) {
const header = request.headers.authorization ?? "";
const expected = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`;
if (header !== expected) {
response.writeHead(401, { "www-authenticate": 'Basic realm="Secure Area"' });
response.end();
return;
}
}
const prefix = options.apiPrefix ? "/api" : "";
if (pathname === `${prefix}/provider`) return json(response, providerPayload);
if (pathname === `${prefix}/agent`) return json(response, []);
if (options.apiPrefix) {
// web-ui.ts fallback: every unmatched route yields the SPA shell.
response.writeHead(200, { "content-type": "text/html" });
response.end("<!doctype html><html><body>opencode</body></html>");
return;
}
return json(response, []);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing test server address");
return {
port: address.port,
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
};
}
function json(response: ServerResponse, value: unknown): void {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(value));
}
class FakeServerProcess extends EventEmitter {
readonly stdout = new EventEmitter();
readonly stderr = new EventEmitter();
readonly pid = 42_101;
killed = false;
exitCode: number | null = null;
signalCode: NodeJS.Signals | null = null;
constructor() {
super();
queueMicrotask(() => this.stdout.emit("data", Buffer.from("listening on test server\n")));
}
exit(): void {
if (this.exitCode !== null) return;
this.exitCode = 0;
this.emit("exit", 0, null);
}
}
function createClientFor(port: number) {
let serverProcess: FakeServerProcess | null = null;
const manager = new OpenCodeServerManager({
logger: createTestLogger(),
portAllocator: async () => port,
resolveCommandPrefix: async () => ({ command: "opencode", args: [] }),
resolveHomeDir: () => process.cwd(),
spawnServerProcess: () => {
serverProcess = new FakeServerProcess();
return serverProcess as unknown as ChildProcess;
},
terminateProcess: async () => {
serverProcess?.exit();
return "terminated";
},
createEventSource: (options) => new OpenCodeEventConsumer(options),
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: manager,
createClient: ({ baseUrl, directory }) => createOpencodeClient({ baseUrl, directory }),
});
return { manager, client };
}
/**
* Ablation over the two things opencode v2 changed. Each case reports the
* error Paseo surfaces today, so the decisive factor is visible: the
* mandatory server password fails first and produces the reported string,
* masking the /api route move behind it.
*/
const CASES = [
{
name: "v1: no auth, bare routes — discovery works",
auth: false,
apiPrefix: false,
error: null,
},
{
name: "v2 password only: bare routes — reported symptom",
auth: true,
apiPrefix: false,
error: "Failed to fetch OpenCode providers: {}",
},
{
name: "v2 routes only: no password — distinct, legible error",
auth: false,
apiPrefix: true,
error: "Request is not supported by this version of OpenCode Server",
},
{
name: "v2.0.3: password and /api routes — password fails first",
auth: true,
apiPrefix: true,
error: "Failed to fetch OpenCode providers: {}",
},
] as const;
for (const testCase of CASES) {
test(`#4878 ${testCase.name}`, async () => {
const upstream = await createUpstream(testCase);
const { manager, client } = createClientFor(upstream.port);
try {
const attempt = client.fetchCatalog({ scope: "global", cwd: process.cwd(), force: false });
if (testCase.error === null) {
const catalog = await attempt;
expect(catalog.models.map((model) => model.id)).toContain("synthetic/hf:zai-org/GLM-4.6");
} else {
await expect(attempt).rejects.toThrow(testCase.error);
}
} finally {
await manager.shutdown();
await upstream.close();
}
});
}
// Red repro for #4878: the behaviour users expect from a supported provider.
test("#4878 discovery lists models against an OpenCode v2.0.3 server", async () => {
const upstream = await createUpstream({ auth: true, apiPrefix: true });
const { manager, client } = createClientFor(upstream.port);
try {
const catalog = await client.fetchCatalog({
scope: "global",
cwd: process.cwd(),
force: false,
});
expect(catalog.models.map((model) => model.id)).toContain("synthetic/hf:zai-org/GLM-4.6");
} finally {
await manager.shutdown();
await upstream.close();
}
});