Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/packages/devo-ai-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"type": "module",
"exports": {
"./v2/client": "./src/v2/client.ts",
"./v2/protocol-validation": "./src/v2/protocol-validation.ts",
"./v2/native-client-support": "./src/v2/native-client-support.ts"
},
"dependencies": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class FakeNativeTransport implements DevoNativeTransport {
pendingControlRequests: unknown[] = []
subscriptionCursors: Array<{ streamId: string; seq: number }> = []
sessionItems: unknown[] = []
resumeSession?: unknown

async request(method: string, params?: unknown, directory?: string): Promise<unknown> {
this.requests.push({ method, params, directory })
Expand Down Expand Up @@ -47,9 +48,11 @@ class FakeNativeTransport implements DevoNativeTransport {
return { views: [nativeWorkspaceView] }
case "turn/start":
return { turn: nativeTurnInProgress }
case "session/message/edit":
return editedMessageResult
case "session/resume":
return {
session: nativeSession,
session: this.resumeSession ?? nativeSession,
lastContextOccupancy: nativeOccupancy,
}
case "session/items/list":
Expand Down Expand Up @@ -155,6 +158,26 @@ const nativeTurnCompleted = {
completedAt: "2026-08-24T00:00:08Z",
}

const editedMessageResult = {
editState: "accepted",
replacementTurnId: "turn-2",
item: {
id: "item-user-edited",
sessionId: nativeSession.id,
turnId: "turn-2",
revision: 2,
seq: 1,
state: "completed",
createdAt: "2026-08-24T00:00:10.000Z",
updatedAt: "2026-08-24T00:00:10.000Z",
item: {
type: "userMessage",
content: [{ type: "text", text: "edited" }],
entry: "turnStart",
},
},
}

const nativeOccupancy = {
totalTokens: 100_000,
contextWindowTokens: 200_000,
Expand Down Expand Up @@ -1197,4 +1220,122 @@ describe("Native desktop SDK interactions", () => {
expect(after?.time.lastActivity).toBe(Date.parse("2026-08-24T00:00:08Z"))
expect(after?.time.updated).toBe(Date.parse("2026-08-24T00:00:08Z"))
})

test("loading session history emits the resume-enriched snapshot as session.updated", async () => {
const transport = new FakeNativeTransport()
transport.resumeSession = {
...nativeSession,
model: { provider: "test", model: "alt-model" },
settings: { ...nativeSession.settings, reasoningEffort: "enabled", mode: "plan" },
}
const client = createDevoClient({ directory: "/repo", transport })
const stream = (await client.global.event()).stream[Symbol.asyncIterator]()
await client.session.messages({ sessionID: nativeSession.id })

// The cold session/list snapshot carries the base model; resume is
// authoritative for persisted per-session selections, so its enriched
// snapshot must reach renderer session stores (they re-seed the
// composer from session.updated) instead of staying client-internal.
const update = await nextPayloadOfType(stream, "session.updated")
expect(update.properties.session.model?.model).toBe("alt-model")
expect(update.properties.session.settings?.reasoningEffort).toBe("enabled")
expect(update.properties.session.settings?.mode).toBe("plan")
})

test("session/message/edit sends canonical params", async () => {
const transport = new FakeNativeTransport()
const client = createDevoClient({ directory: "/repo", transport })
await client.session.create()
await client.session.editMessage({
sessionID: nativeSession.id,
itemID: "item-user-1",
text: "edited",
})
const request = transport.requests.find((entry) => entry.method === "session/message/edit")
const params = (request?.params ?? {}) as {
sessionId?: string
itemId?: string
expectedRevision?: number
content?: unknown
idempotencyKey?: string
}
expect({
method: request?.method,
sessionId: params.sessionId,
itemId: params.itemId,
expectedRevision: params.expectedRevision,
content: params.content,
hasIdempotencyKey: typeof params.idempotencyKey === "string" && params.idempotencyKey.length > 0,
}).toEqual({
method: "session/message/edit",
sessionId: nativeSession.id,
itemId: "item-user-1",
expectedRevision: 0,
content: [{ type: "text", text: "edited" }],
hasIdempotencyKey: true,
})
})

test("turn/superseded removes messages from the replaced turn", async () => {
const transport = new FakeNativeTransport()
transport.sessionItems = [
{
id: "item-user-1",
sessionId: nativeSession.id,
turnId: "turn-1",
seq: 1,
revision: 1,
createdAt: "2026-08-24T00:00:00.000Z",
updatedAt: "2026-08-24T00:00:00.000Z",
state: "completed",
item: {
type: "userMessage",
content: [{ type: "text", text: "hello" }],
entry: "turnStart",
},
},
{
id: "item-assistant-1",
sessionId: nativeSession.id,
turnId: "turn-1",
seq: 2,
revision: 1,
createdAt: "2026-08-24T00:00:02.000Z",
updatedAt: "2026-08-24T00:00:14.000Z",
state: "completed",
item: {
type: "assistantMessage",
text: "world",
},
},
]
const client = createDevoClient({ directory: "/repo", transport })
const loaded = await client.session.messages({ sessionID: nativeSession.id })
expect(loaded.data.map((entry) => entry.info.id).sort()).toEqual([
"item-assistant-1",
"item-user-1",
])
const stream = (await client.global.event()).stream[Symbol.asyncIterator]()
transport.emit({
type: "notification",
method: "turn/superseded",
params: {
sessionId: nativeSession.id,
supersededTurnId: "turn-1",
replacementTurnId: "turn-2",
editId: "edit-1",
reason: "message_edit_previous",
},
})
expect(await nextPayloadOfType(stream, "message.removed")).toEqual({
type: "message.removed",
properties: { sessionID: nativeSession.id, messageID: "item-user-1" },
})
expect(await nextPayloadOfType(stream, "message.removed")).toEqual({
type: "message.removed",
properties: { sessionID: nativeSession.id, messageID: "item-assistant-1" },
})
const remaining = await client.session.messages({ sessionID: nativeSession.id })
expect(remaining.data).toEqual([])
})
})
111 changes: 111 additions & 0 deletions apps/desktop/packages/devo-ai-sdk/src/v2/client-session-fork.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test"
import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEvent } from "./client"

class FakeTransport implements DevoNativeTransport {
readonly requests: Array<{ method: string; params: unknown }> = []
private listeners: Array<(event: DevoNativeTransportEvent) => void> = []

constructor(private readonly handler: (method: string, params: unknown) => unknown) {}

async request(method: string, params?: unknown): Promise<unknown> {
this.requests.push({ method, params })
return this.handler(method, params)
}

async respond(): Promise<void> {}

subscribe(listener: (event: DevoNativeTransportEvent) => void): () => void {
this.listeners.push(listener)
return () => {
this.listeners = this.listeners.filter((item) => item !== listener)
}
}

connected(): boolean {
return true
}
}

const forkedSession = {
id: "child-session",
version: 1,
cwd: "/repo",
title: "Forked",
parent: null,
forkFromId: "parent-session",
atTurnId: "turn-2",
createdAt: "2026-01-01T00:00:00.000Z",
lastActivityAt: "2026-01-01T00:00:00.000Z",
status: "idle",
flags: [],
archived: false,
ephemeral: false,
model: { provider: "test", model: "test-model" },
settings: { permissionProfile: "default" },
preview: "",
queuedCount: 0,
usage: {
total: {
inputTokens: 0,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
reasoningTokens: 0,
totalTokens: 0,
callCount: 0,
meteredCallCount: 0,
failedCallCount: 0,
cancelledCallCount: 0,
},
byPurpose: [],
updatedAt: "2026-01-01T00:00:00.000Z",
},
}

describe("session.fork", () => {
test("sends canonical session/fork params and remembers the child", async () => {
const transport = new FakeTransport((method, params) => {
if (method === "initialize") {
return {
protocolVersion: 1,
agentCapabilities: {},
authMethods: [],
}
}
if (method === "session/fork") {
expect(params).toEqual({
sessionId: "parent-session",
atTurnId: "turn-2",
cut: "before",
})
return { session: forkedSession }
}
if (method === "session/resume") {
return { session: forkedSession }
}
if (method === "session/items/list") {
return { data: [], nextCursor: null }
}
if (method === "subscription/create") {
return { subscriptionId: "sub-1", cursors: [] }
}
throw new Error(`unexpected method ${method}`)
})

const client = createDevoClient({
directory: "/repo",
transport,
})

const result = await client.session.fork({
sessionID: "parent-session",
atTurnId: "turn-2",
cut: "before",
})

expect(result.data.id).toBe("child-session")
expect(result.data.forkFromId).toBe("parent-session")
expect(result.data.parentID).toBeUndefined()
expect(transport.requests.some((request) => request.method === "session/fork")).toBe(true)
})
})
Loading
Loading