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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

149 changes: 149 additions & 0 deletions apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ class FakeTransport implements DevoAcpTransport {
}
}

emitNotification(method: string, params: unknown): void {
for (const listener of this.listeners) {
listener({ type: "notification", method, params })
}
}

emitRequest(id: string | number, method: string, params: unknown): void {
for (const listener of this.listeners) {
listener({ type: "request", id, method, params })
Expand Down Expand Up @@ -82,6 +88,36 @@ const initializeResult = {
authMethods: [],
}

const workspaceChangeView = {
scope: "turn",
status: "ready",
workspace_root: "/repo",
base: {
kind: "turn_checkpoint",
turn_id: "t1",
checkpoint_id: "checkpoint-1",
backend: "git_ghost_commit",
},
coverage: "git_visible",
attribution: "workspace_net",
change_set_status: "finalized",
files: [
{
path: "src/main.rs",
status: "modified",
additions: 2,
deletions: 1,
binary: false,
diff_truncated: false,
},
],
stats: { files_changed: 1, additions: 2, deletions: 1 },
unified_diff:
"diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n-old\n+new\n",
warnings: [],
generated_at: "2026-06-26T00:00:00Z",
}

const originalNow = Date.now

afterEach(() => {
Expand Down Expand Up @@ -1029,6 +1065,119 @@ describe("ACP desktop SDK session mapping", () => {
])
})

test("reads workspace changes through the runtime workspace API", async () => {
const transport = new FakeTransport((method, params, directory) => {
if (method === "_devo/workspace/changes/read") {
expect(directory).toBe("/repo")
expect(params).toEqual({
session_id: "s1",
scopes: ["turn"],
diff_detail: "full",
turn_id: "t1",
max_diff_bytes: 2_000_000,
})
return { views: [workspaceChangeView] }
}
throw new Error(`unexpected request ${method}`)
})
const client = createDevoClient({ directory: "/repo", transport })

const result = await client.workspace.changes.read({
sessionID: "s1",
scopes: ["turn"],
turnID: "t1",
diffDetail: "full",
maxDiffBytes: 2_000_000,
})

expect(result.data).toEqual({ views: [workspaceChangeView] })
})

test("emits workspace change events from direct workspace notifications", async () => {
const transport = new FakeTransport((method) => {
if (method === "initialize") return initializeResult
throw new Error(`unexpected request ${method}`)
})
const client = createDevoClient({ directory: "/repo", transport })
const stream = (await client.global.event()).stream[Symbol.asyncIterator]()

transport.emitNotification("workspace/changes/updated", {
session_id: "s1",
turn_id: "t1",
scope: "turn",
status: "ready",
coverage: "git_visible",
change_set_status: "finalized",
stats: { files_changed: 1, additions: 2, deletions: 1 },
version: 42,
generated_at: "2026-06-26T00:00:00Z",
})

expect(await nextPayload(stream, "workspace-direct")).toEqual({
type: "workspace.changes.updated",
properties: {
sessionID: "s1",
turnID: "t1",
scope: "turn",
status: "ready",
coverage: "git_visible",
changeSetStatus: "finalized",
stats: { filesChanged: 1, additions: 2, deletions: 1 },
version: 42,
generatedAt: "2026-06-26T00:00:00Z",
},
})
})

test("emits workspace change events from wrapped original server events", async () => {
const transport = new FakeTransport((method) => {
if (method === "initialize") return initializeResult
if (method === "session/list") return { sessions: [sessionInfo] }
throw new Error(`unexpected request ${method}`)
})
const client = createDevoClient({ directory: "/repo", transport })
const stream = (await client.global.event()).stream[Symbol.asyncIterator]()

await client.session.list()
transport.emitSessionUpdate({
sessionId: "s1",
update: { sessionUpdate: "session_info_update" },
_meta: {
"devo/originalEvent": {
kind: "workspace_changes_updated",
session_id: "s1",
turn_id: "t1",
scope: "turn",
status: "ready",
coverage: "git_visible",
change_set_status: "finalized",
stats: { files_changed: 1, additions: 2, deletions: 1 },
version: 43,
generated_at: "2026-06-26T00:00:01Z",
},
},
} satisfies AcpSessionNotification)

expect(await nextPayload(stream, "workspace-session-update")).toEqual({
type: "session.updated",
properties: { info: expect.any(Object), session: expect.any(Object) },
})
expect(await nextPayload(stream, "workspace-wrapped")).toEqual({
type: "workspace.changes.updated",
properties: {
sessionID: "s1",
turnID: "t1",
scope: "turn",
status: "ready",
coverage: "git_visible",
changeSetStatus: "finalized",
stats: { filesChanged: 1, additions: 2, deletions: 1 },
version: 43,
generatedAt: "2026-06-26T00:00:01Z",
},
})
})

test("maps original request_user_input events to questions and replies through runtime API", async () => {
const transport = new FakeTransport((method) => {
if (method === "initialize") return initializeResult
Expand Down
172 changes: 172 additions & 0 deletions apps/desktop/packages/devo-ai-sdk/src/v2/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,15 @@ import type {
ModelConfigParams,
ModelConfigResult,
RequestUserInputRespondParams,
WorkspaceChangeCoverage,
WorkspaceChangeScope,
WorkspaceChangeSetStatus,
WorkspaceChangeStats,
WorkspaceChangeViewStatus,
WorkspaceChangesReadParams,
WorkspaceChangesReadResult,
WorkspaceChangesUpdatedPayload,
WorkspaceDiffDetail,
} from "./generated/protocol"
import {
ProtocolValidationError,
Expand Down Expand Up @@ -122,6 +131,48 @@ export type ToolState = any
export type ToolStateCompleted = any
export type UserMessage = any
export type Worktree = any
export type {
WorkspaceChangeAttribution,
WorkspaceChangeBase,
WorkspaceChangeCoverage,
WorkspaceChangeScope,
WorkspaceChangeSetStatus,
WorkspaceChangeStats,
WorkspaceChangeView,
WorkspaceChangeViewStatus,
WorkspaceChangedFile,
WorkspaceChangedFileStatus,
WorkspaceChangesReadParams,
WorkspaceChangesReadResult,
WorkspaceChangesUpdatedPayload,
WorkspaceDiffDetail,
} from "./generated/protocol"

export type WorkspaceChangesReadOptions = {
sessionID: string
cwd?: string
scopes: WorkspaceChangeScope[]
baseBranch?: string
turnID?: string
diffDetail?: WorkspaceDiffDetail
maxDiffBytes?: number | bigint
}

export type WorkspaceChangesUpdatedEventProperties = {
sessionID: string
turnID: string
scope: WorkspaceChangeScope
status: WorkspaceChangeViewStatus
coverage: WorkspaceChangeCoverage
changeSetStatus: WorkspaceChangeSetStatus
stats: {
filesChanged: number
additions: number
deletions: number
}
version: number
generatedAt: string
}

interface GlobalEvent {
directory: string
Expand Down Expand Up @@ -153,6 +204,71 @@ function sessionMeta(value: unknown): Record<string, unknown> | undefined {
return objectRecord(meta?.["devo/session"])
}

function numberFromProtocol(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value
if (typeof value === "bigint") return Number(value)
if (typeof value === "string") {
const parsed = Number(value)
if (Number.isFinite(parsed)) return parsed
}
return 0
}

function workspaceChangeStats(value: unknown): WorkspaceChangeStats {
const stats = objectRecord(value)
return {
files_changed: numberFromProtocol(stats?.files_changed ?? stats?.filesChanged),
additions: numberFromProtocol(stats?.additions),
deletions: numberFromProtocol(stats?.deletions),
}
}

function workspaceChangesUpdatedFromOriginalEvent(
original: unknown,
): WorkspaceChangesUpdatedPayload | null {
const event = objectRecord(original)
if (!event) return null
const payload =
event.kind === "workspace_changes_updated"
? event
: objectRecord(event.WorkspaceChangesUpdated) ??
objectRecord(event.workspace_changes_updated)
if (!payload) return null
return {
session_id: String(payload.session_id ?? payload.sessionId ?? ""),
turn_id: String(payload.turn_id ?? payload.turnId ?? ""),
scope: String(payload.scope ?? "turn") as WorkspaceChangeScope,
status: String(payload.status ?? "ready") as WorkspaceChangeViewStatus,
coverage: String(payload.coverage ?? "none") as WorkspaceChangeCoverage,
change_set_status: String(
payload.change_set_status ?? payload.changeSetStatus ?? "finalized",
) as WorkspaceChangeSetStatus,
stats: workspaceChangeStats(payload.stats),
version: numberFromProtocol(payload.version),
generated_at: String(payload.generated_at ?? payload.generatedAt ?? ""),
}
}

function workspaceChangesUpdatedEventProperties(
payload: WorkspaceChangesUpdatedPayload,
): WorkspaceChangesUpdatedEventProperties {
return {
sessionID: payload.session_id,
turnID: payload.turn_id,
scope: payload.scope,
status: payload.status,
coverage: payload.coverage,
changeSetStatus: payload.change_set_status,
stats: {
filesChanged: numberFromProtocol(payload.stats.files_changed),
additions: numberFromProtocol(payload.stats.additions),
deletions: numberFromProtocol(payload.stats.deletions),
},
version: numberFromProtocol(payload.version),
generatedAt: payload.generated_at,
}
}

function parseTimestampMs(value: unknown): number | undefined {
if (typeof value !== "string") return undefined
const parsed = Date.parse(value)
Expand Down Expand Up @@ -441,6 +557,29 @@ class AcpClient {
},
}

workspace = {
changes: {
read: async (params: WorkspaceChangesReadOptions) => {
const wireParams: WorkspaceChangesReadParams = {
session_id: params.sessionID,
scopes: params.scopes,
diff_detail: params.diffDetail ?? "summary",
}
if (params.cwd !== undefined) wireParams.cwd = params.cwd
if (params.baseBranch !== undefined) wireParams.base_branch = params.baseBranch
if (params.turnID !== undefined) wireParams.turn_id = params.turnID
if (params.maxDiffBytes !== undefined) {
wireParams.max_diff_bytes = Number(params.maxDiffBytes)
}
const data = (await this.request(
"_devo/workspace/changes/read",
wireParams,
)) as WorkspaceChangesReadResult
return { data }
},
},
}

command = {
list: async () => ({ data: [{ name: "compact", description: "Compact the session" }] }),
}
Expand Down Expand Up @@ -718,6 +857,21 @@ class AcpClient {
this.handleSessionUpdate(notification)
return
}
if (
event.type === "notification" &&
(event.method === "workspace/changes/updated" ||
event.method === "_devo/workspace/changes/updated") &&
event.params
) {
const payload = this.validateTransportPayload<WorkspaceChangesUpdatedPayload>(
event.method,
"incomingNotification",
event.params,
)
if (!payload) return
this.handleWorkspaceChangesUpdated(payload)
return
}
if (event.type === "request" && event.id !== undefined && event.method) {
const params = this.validateTransportPayload(event.method, "incomingRequest", event.params)
if (!params) return
Expand Down Expand Up @@ -973,6 +1127,10 @@ class AcpClient {
const payload = (original as { RequestUserInput: Record<string, unknown> }).RequestUserInput
this.handleRequestUserInput(sessionId, directory, payload)
}
const workspaceChanges = workspaceChangesUpdatedFromOriginalEvent(original)
if (workspaceChanges) {
this.handleWorkspaceChangesUpdated(workspaceChanges, directory)
}
if ("ServerRequestResolved" in original) {
const payload = (original as { ServerRequestResolved: Record<string, unknown> })
.ServerRequestResolved
Expand All @@ -987,6 +1145,20 @@ class AcpClient {
}
}

private handleWorkspaceChangesUpdated(
payload: WorkspaceChangesUpdatedPayload,
directory?: string,
): void {
const event = workspaceChangesUpdatedEventProperties(payload)
if (!event.sessionID) return
const emitDirectory =
directory ?? this.sessionDirectories.get(event.sessionID) ?? this.options.directory ?? defaultCwd()
this.emit(emitDirectory, {
type: "workspace.changes.updated",
properties: event,
})
}

private handleRequestUserInput(
sessionId: string,
directory: string,
Expand Down
Loading
Loading