mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 21:32:40 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0849dec44e | ||
|
|
c8a7667a03 | ||
|
|
4c051388e1 | ||
|
|
91d9cf1dbd |
+1
-1
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user