mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
244f957b8f | ||
|
|
bc5bc969c8 | ||
|
|
8186585201 | ||
|
|
eb665a55a8 | ||
|
|
d22737c77a | ||
|
|
aeb98f813a | ||
|
|
c8a7667a03 | ||
|
|
4c051388e1 | ||
|
|
91d9cf1dbd |
@@ -90,3 +90,6 @@ packages/protocol/src/generated/validation/*.aot.ts
|
||||
packages/desktop/.cache/
|
||||
packages/desktop/src-tauri/resources/managed-runtime/
|
||||
app.json
|
||||
|
||||
# Heap snapshots dumped by --heapsnapshot-near-heap-limit; keep them for debugging.
|
||||
*.heapsnapshot
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 0.9.1 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer ([#5200](https://github.com/getpaseo/paseo/pull/5200) by [@leonardourci](https://github.com/leonardourci), [@sebgalind0](https://github.com/sebgalind0), [@rp4ri](https://github.com/rp4ri))
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon ([#5189](https://github.com/getpaseo/paseo/pull/5189) by [@bagutzu](https://github.com/bagutzu))
|
||||
- Fixed Import session offering only the newest 100 Codex conversations ([#5174](https://github.com/getpaseo/paseo/pull/5174) by [@3ae3ae](https://github.com/3ae3ae))
|
||||
- Fixed a Claude model whose ID carries a minor version, such as Opus 5.5, reverting to its major version in the composer once a turn finished ([#5200](https://github.com/getpaseo/paseo/pull/5200))
|
||||
|
||||
## 0.9.0 - 2026-09-22
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
@@ -0,0 +1,8 @@
|
||||
Added
|
||||
- Added Opus 5.5 to the Claude catalog as its default model, with a 1M context window and Fast Mode, on Claude Code 2.1.280 and newer
|
||||
|
||||
Fixed
|
||||
- Fixed the sidebar keeping only recently changed conversations after the app reconnects to a daemon
|
||||
- Fixed Import session offering only the newest 100 Codex conversations
|
||||
|
||||
Full notes: https://paseo.sh/changelog
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-6du3a0DzuvF+m2cPTOXDhdZZScQPkqicikdDxWIKZ5o=
|
||||
sha256-9UWtpZrCdyYyGq3HGNgSpU1+2Imu3oYqtSumq2DtANc=
|
||||
|
||||
Generated
+27
-27
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "paseo",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
@@ -36196,7 +36196,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/language": "6.12.4",
|
||||
@@ -37262,12 +37262,12 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/server": "0.9.0",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/server": "0.9.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
@@ -37514,10 +37514,10 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/relay": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37528,7 +37528,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
@@ -37772,7 +37772,7 @@
|
||||
},
|
||||
"packages/expo-two-way-audio": {
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
@@ -38668,7 +38668,7 @@
|
||||
},
|
||||
"packages/highlight": {
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/legacy-modes": "^6.5.3",
|
||||
@@ -38697,14 +38697,14 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^1.4.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -38714,8 +38714,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38737,7 +38737,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"semver": "^7.8.5",
|
||||
"zod": "^4.4.3"
|
||||
@@ -38764,7 +38764,7 @@
|
||||
},
|
||||
"packages/relay": {
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.5.1",
|
||||
"tweetnacl": "^1.0.3",
|
||||
@@ -38982,16 +38982,16 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/highlight": "0.9.0",
|
||||
"@getpaseo/plugin": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/relay": "0.9.0",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/highlight": "0.9.1",
|
||||
"@getpaseo/plugin": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
@@ -39895,7 +39895,7 @@
|
||||
},
|
||||
"packages/website": {
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.29.1",
|
||||
"@cloudflare/workers-types": "^4.20260317.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paseo",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/cli",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||
"bin": {
|
||||
"paseo": "bin/paseo"
|
||||
@@ -28,9 +28,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.0",
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/server": "0.9.0",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/server": "0.9.1",
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^12.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
|
||||
@@ -52,10 +52,15 @@ interface ProviderDiagnostic {
|
||||
}
|
||||
|
||||
const EXPECTED_CLAUDE_MODELS = [
|
||||
{
|
||||
id: "claude-opus-5-5",
|
||||
model: "Opus 5.5",
|
||||
descriptionFragment: "Latest release",
|
||||
},
|
||||
{
|
||||
id: "claude-opus-5",
|
||||
model: "Opus 5",
|
||||
descriptionFragment: "Latest release",
|
||||
descriptionFragment: "Previous release",
|
||||
},
|
||||
{
|
||||
id: "claude-fable-5-1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/client",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo client SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -39,8 +39,8 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/relay": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/desktop",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"description": "Paseo desktop app (Electron wrapper)",
|
||||
"homepage": "https://paseo.sh",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/expo-two-way-audio",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"description": "Native module for two way audio streaming",
|
||||
"keywords": [
|
||||
"ExpoTwoWayAudio",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.map",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/plugin",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo plugin SDK package",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -65,8 +65,8 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/react": "~19.2.0",
|
||||
"react": "19.1.0",
|
||||
@@ -76,8 +76,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"react": "~19.1.0",
|
||||
"react-native": ">=0.81.5",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/protocol",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo shared protocol schemas and wire types",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/relay",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo relay for bridging daemon and client connections",
|
||||
"files": [
|
||||
"dist",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -106,11 +106,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.9.0",
|
||||
"@getpaseo/highlight": "0.9.0",
|
||||
"@getpaseo/plugin": "0.9.0",
|
||||
"@getpaseo/protocol": "0.9.0",
|
||||
"@getpaseo/relay": "0.9.0",
|
||||
"@getpaseo/client": "0.9.1",
|
||||
"@getpaseo/highlight": "0.9.1",
|
||||
"@getpaseo/plugin": "0.9.1",
|
||||
"@getpaseo/protocol": "0.9.1",
|
||||
"@getpaseo/relay": "0.9.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -476,7 +476,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5");
|
||||
expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
|
||||
expect(models.map((model) => model.id)).toContain("claude-fable-5");
|
||||
} finally {
|
||||
await fs.rm(emptyConfigDir, { recursive: true, force: true });
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentStreamEvent, AgentSession } from "../../agent-sdk-types.js";
|
||||
import {
|
||||
canRunRealProvider,
|
||||
createRealProviderClient,
|
||||
} from "../../../daemon-e2e/real-provider-test-config.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
|
||||
// Adaptive thinking is the model's choice, so the prompt has to be hard enough
|
||||
// that it always reasons. A trivial sum does not think at all.
|
||||
const THINKING_PROMPT =
|
||||
"You have 12 identical-looking balls; exactly one has a different weight (you do not " +
|
||||
"know if heavier or lighter). Using a balance scale only 3 times, give the complete " +
|
||||
"decision procedure that always identifies the odd ball AND whether it is heavy or " +
|
||||
"light. Work out every branch.";
|
||||
|
||||
function isTerminalEvent(event: AgentStreamEvent): boolean {
|
||||
return (
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled"
|
||||
);
|
||||
}
|
||||
|
||||
async function collectUntilTerminal(session: AgentSession): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamSession(session, THINKING_PROMPT)) {
|
||||
events.push(event);
|
||||
if (isTerminalEvent(event)) {
|
||||
return events;
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
function reasoningTexts(events: AgentStreamEvent[]): string[] {
|
||||
return events.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "reasoning" ? [event.item.text] : [],
|
||||
);
|
||||
}
|
||||
|
||||
describe("Claude thinking display (real)", () => {
|
||||
let canRun = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
canRun = await canRunRealProvider("claude");
|
||||
});
|
||||
|
||||
beforeEach((context) => {
|
||||
if (!canRun) {
|
||||
context.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test("emits reasoning timeline items when thinking is enabled", async () => {
|
||||
const client = createRealProviderClient("claude", pino({ level: "silent" }));
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
modeId: "bypassPermissions",
|
||||
model: "claude-sonnet-5",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
try {
|
||||
const events = await collectUntilTerminal(session);
|
||||
const failure = events.find((event) => event.type === "turn_failed");
|
||||
expect(failure, JSON.stringify(failure)).toBeUndefined();
|
||||
|
||||
const reasoning = reasoningTexts(events);
|
||||
expect(reasoning.length).toBeGreaterThan(0);
|
||||
expect(reasoning.join("").trim().length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await session.close().catch(() => undefined);
|
||||
}
|
||||
}, 180_000);
|
||||
});
|
||||
@@ -11,6 +11,7 @@ interface ClaudeModelManifestEntry {
|
||||
minimumClaudeCodeVersion?: string;
|
||||
contextWindowMaxTokens?: number;
|
||||
effortLevels?: readonly ClaudeEffortLevel[];
|
||||
defaultThinkingOptionId?: ClaudeEffortLevel;
|
||||
supportsThinkingDisabled?: boolean;
|
||||
supportsFastMode?: boolean;
|
||||
}
|
||||
@@ -34,10 +35,21 @@ export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off";
|
||||
export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
|
||||
|
||||
export const CLAUDE_MODEL_MANIFEST = [
|
||||
{
|
||||
id: "claude-opus-5-5",
|
||||
label: "Opus 5.5",
|
||||
description: "Opus 5.5 · Latest release",
|
||||
defaultPriority: 3,
|
||||
minimumClaudeCodeVersion: "2.1.280",
|
||||
defaultThinkingOptionId: "medium",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
|
||||
supportsFastMode: true,
|
||||
},
|
||||
{
|
||||
id: "claude-opus-5",
|
||||
label: "Opus 5",
|
||||
description: "Opus 5 · Latest release",
|
||||
description: "Opus 5 · Previous release",
|
||||
defaultPriority: 2,
|
||||
minimumClaudeCodeVersion: "2.1.219",
|
||||
contextWindowMaxTokens: 1_000_000,
|
||||
@@ -157,9 +169,14 @@ export const CLAUDE_MODEL_MANIFEST = [
|
||||
},
|
||||
] as const satisfies readonly ClaudeModelManifestEntry[];
|
||||
|
||||
function getDefaultThinkingOptionId(model: ClaudeModelManifestEntry): ClaudeEffortLevel {
|
||||
return model.defaultThinkingOptionId ?? CLAUDE_DEFAULT_THINKING_OPTION_ID;
|
||||
}
|
||||
|
||||
function buildThinkingOptions(
|
||||
effortLevels: readonly ClaudeEffortLevel[] | undefined,
|
||||
supportsThinkingDisabled: boolean,
|
||||
defaultThinkingOptionId: ClaudeEffortLevel,
|
||||
): AgentSelectOption[] | undefined {
|
||||
if (!effortLevels) {
|
||||
return undefined;
|
||||
@@ -170,7 +187,7 @@ function buildThinkingOptions(
|
||||
...effortLevels.map((id) => ({
|
||||
id,
|
||||
label: CLAUDE_EFFORT_LABELS[id],
|
||||
...(id === CLAUDE_DEFAULT_THINKING_OPTION_ID ? { isDefault: true } : {}),
|
||||
...(id === defaultThinkingOptionId ? { isDefault: true } : {}),
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -196,6 +213,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
|
||||
const thinkingOptions = buildThinkingOptions(
|
||||
model.effortLevels,
|
||||
model.supportsThinkingDisabled === true,
|
||||
getDefaultThinkingOptionId(model),
|
||||
);
|
||||
const definition: AgentModelDefinition = {
|
||||
provider: "claude",
|
||||
@@ -214,7 +232,7 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD
|
||||
}
|
||||
if (thinkingOptions) {
|
||||
definition.thinkingOptions = thinkingOptions;
|
||||
definition.defaultThinkingOptionId = CLAUDE_DEFAULT_THINKING_OPTION_ID;
|
||||
definition.defaultThinkingOptionId = getDefaultThinkingOptionId(model);
|
||||
}
|
||||
definitions.push(definition);
|
||||
if (!("aliases" in model) || !model.aliases) {
|
||||
@@ -286,7 +304,7 @@ export function resolveClaudeDisabledThinkingForModel(
|
||||
supported:
|
||||
!!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true,
|
||||
fallbackThinkingOptionId:
|
||||
model && "effortLevels" in model ? CLAUDE_DEFAULT_THINKING_OPTION_ID : undefined,
|
||||
model && "effortLevels" in model ? getDefaultThinkingOptionId(model) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -350,7 +368,9 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
|
||||
/**
|
||||
* Normalize a Claude Code runtime/config model string to a known manifest ID.
|
||||
* Runtime metadata may include provider prefixes such as Bedrock model IDs; feature
|
||||
* gates should use normalizeClaudeManifestModelId instead.
|
||||
* gates should use normalizeClaudeManifestModelId instead. The prefixed matches are
|
||||
* unanchored, so major-minor runs first: "claude-opus-5-5" would otherwise stop at the
|
||||
* "claude-opus-5" entry.
|
||||
*/
|
||||
export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null {
|
||||
const normalizedManifestModelId = normalizeClaudeManifestModelId(value);
|
||||
@@ -363,13 +383,14 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
|
||||
return null;
|
||||
}
|
||||
|
||||
const singleSegmentMatch = trimmed.match(
|
||||
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
|
||||
const runtimeMatch = trimmed.match(
|
||||
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
||||
);
|
||||
if (singleSegmentMatch) {
|
||||
const normalizedModelId = normalizeSingleSegmentClaudeModelId(
|
||||
singleSegmentMatch[1],
|
||||
singleSegmentMatch[2],
|
||||
if (runtimeMatch) {
|
||||
const normalizedModelId = normalizeMajorMinorClaudeModelId(
|
||||
runtimeMatch[1],
|
||||
runtimeMatch[2],
|
||||
runtimeMatch[3],
|
||||
trimmed.toLowerCase().includes("[1m]"),
|
||||
);
|
||||
if (normalizedModelId) {
|
||||
@@ -377,17 +398,16 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeMatch = trimmed.match(
|
||||
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
|
||||
const singleSegmentMatch = trimmed.match(
|
||||
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
|
||||
);
|
||||
if (!runtimeMatch) {
|
||||
if (!singleSegmentMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeMajorMinorClaudeModelId(
|
||||
runtimeMatch[1],
|
||||
runtimeMatch[2],
|
||||
runtimeMatch[3],
|
||||
return normalizeSingleSegmentClaudeModelId(
|
||||
singleSegmentMatch[1],
|
||||
singleSegmentMatch[2],
|
||||
trimmed.toLowerCase().includes("[1m]"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ async function createClaudeConfigDirWithRawSettings(settings: string): Promise<s
|
||||
return configDir;
|
||||
}
|
||||
|
||||
function createCatalogClient(claudeCodeVersion = "2.1.219"): ClaudeAgentClient {
|
||||
function createCatalogClient(claudeCodeVersion = "2.1.280"): ClaudeAgentClient {
|
||||
return new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveVersion: async () => claudeCodeVersion,
|
||||
@@ -50,6 +50,7 @@ describe("getClaudeModels", () => {
|
||||
it("returns all claude models", () => {
|
||||
const models = getClaudeModels();
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-opus-5-5",
|
||||
"claude-opus-5",
|
||||
"claude-fable-5-1",
|
||||
"claude-fable-5",
|
||||
@@ -72,7 +73,7 @@ describe("getClaudeModels", () => {
|
||||
const models = getClaudeModels();
|
||||
const defaults = models.filter((m) => m.isDefault);
|
||||
expect(defaults).toHaveLength(1);
|
||||
expect(defaults[0].id).toBe("claude-opus-5");
|
||||
expect(defaults[0].id).toBe("claude-opus-5-5");
|
||||
});
|
||||
|
||||
it("defines context window sizes in the catalog", () => {
|
||||
@@ -82,6 +83,7 @@ describe("getClaudeModels", () => {
|
||||
|
||||
expect(contextWindows).toEqual(
|
||||
new Map([
|
||||
["claude-opus-5-5", 1_000_000],
|
||||
["claude-opus-5", 1_000_000],
|
||||
["claude-fable-5-1", 1_000_000],
|
||||
["claude-fable-5", 1_000_000],
|
||||
@@ -109,6 +111,11 @@ describe("getClaudeModels", () => {
|
||||
|
||||
expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5");
|
||||
expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5");
|
||||
|
||||
expect(getClaudeModels("2.1.279").map((model) => model.id)).not.toContain("claude-opus-5-5");
|
||||
expect(getClaudeModels("2.1.279").find((model) => model.isDefault)?.id).toBe("claude-opus-5");
|
||||
expect(getClaudeModels("2.1.280").map((model) => model.id)).toContain("claude-opus-5-5");
|
||||
expect(getClaudeModels("2.1.280").find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
|
||||
});
|
||||
|
||||
it("derives thinking options from model effort capabilities", () => {
|
||||
@@ -169,6 +176,8 @@ describe("getClaudeModels", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
["claude-opus-5-5", false, "medium"],
|
||||
["claude-opus-5-5-20260401", false, "medium"],
|
||||
["claude-opus-5", true, "high"],
|
||||
["claude-opus-5-20260724", true, "high"],
|
||||
["claude-sonnet-5", true, "high"],
|
||||
@@ -417,6 +426,19 @@ describe("normalizeClaudeRuntimeModelId", () => {
|
||||
"claude-opus-4-8",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collapse a prefixed minor release onto the major it extends", () => {
|
||||
expect(normalizeClaudeRuntimeModelId("anthropic/claude-opus-5-5")).toBe("claude-opus-5-5");
|
||||
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-5-20260401-v1:0")).toBe(
|
||||
"claude-opus-5-5",
|
||||
);
|
||||
expect(normalizeClaudeRuntimeModelId("openrouter/anthropic/claude-fable-5-1")).toBe(
|
||||
"claude-fable-5-1",
|
||||
);
|
||||
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-5-20260724-v1:0")).toBe(
|
||||
"claude-opus-5",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClaudeCodeVersion", () => {
|
||||
@@ -438,7 +460,7 @@ describe("findClaudeModel", () => {
|
||||
describe("Claude Opus 5 catalog", () => {
|
||||
it("offers a single Opus 5 entry with a 1M context window", () => {
|
||||
const opus5Models = getClaudeModels()
|
||||
.filter((model) => model.id.startsWith("claude-opus-5"))
|
||||
.filter((model) => /^claude-opus-5(\[1m\])?$/.test(model.id))
|
||||
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
|
||||
|
||||
expect(opus5Models).toEqual([
|
||||
@@ -515,6 +537,41 @@ describe("Claude Fable 5.1 catalog", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Claude Opus 5.5 catalog", () => {
|
||||
it("offers one Opus 5.5 entry with a 1M context window", () => {
|
||||
const opus55Models = getClaudeModels()
|
||||
.filter((model) => model.id.startsWith("claude-opus-5-5"))
|
||||
.map(({ id, label, contextWindowMaxTokens }) => ({ id, label, contextWindowMaxTokens }));
|
||||
|
||||
expect(opus55Models).toEqual([
|
||||
{ id: "claude-opus-5-5", label: "Opus 5.5", contextWindowMaxTokens: 1_000_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers every effort level except off, because Opus 5.5 cannot disable thinking", () => {
|
||||
const opus55 = getClaudeModels().find((model) => model.id === "claude-opus-5-5");
|
||||
|
||||
expect(opus55?.thinkingOptions?.map((option) => option.id)).toEqual([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
|
||||
]);
|
||||
expect(opus55?.defaultThinkingOptionId).toBe("medium");
|
||||
expect(
|
||||
opus55?.thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
|
||||
).toEqual(["medium"]);
|
||||
});
|
||||
|
||||
it("resolves suffixed and dated Opus 5.5 IDs to the catalog entry", () => {
|
||||
expect(findClaudeModel("claude-opus-5-5[1m]")?.id).toBe("claude-opus-5-5");
|
||||
expect(findClaudeModel("claude-opus-5-5-20260401")?.id).toBe("claude-opus-5-5");
|
||||
expect(findClaudeModel("claude-opus-5-5-20260401[1m]")?.id).toBe("claude-opus-5-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("claudeManifestModelSupportsFastMode", () => {
|
||||
it("keeps fast mode strict to first-party manifest model IDs", () => {
|
||||
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
|
||||
@@ -523,6 +580,7 @@ describe("claudeManifestModelSupportsFastMode", () => {
|
||||
});
|
||||
|
||||
it("supports fast mode on Opus 5 but not on other Claude 5 models", () => {
|
||||
expect(claudeManifestModelSupportsFastMode("claude-opus-5-5")).toBe(true);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-opus-5")).toBe(true);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-sonnet-5")).toBe(false);
|
||||
expect(claudeManifestModelSupportsFastMode("claude-fable-5")).toBe(false);
|
||||
|
||||
@@ -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 : "";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user