Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
263b01c
chore: update star-history chart with sealed token
wangtsiao Jul 8, 2026
5814c97
feat(core): skip context changes message when turn metadata is unchanged
wangtsiao Jul 8, 2026
fa3178f
fix(core): improve interrupted-turn guidance and add last_turn_interr…
wangtsiao Jul 8, 2026
43f1049
feat(protocol): add TurnProviderRetryStatus server event
wangtsiao Jul 8, 2026
6f2b060
feat(cli): emit provider retry status in prompt JSONL events
wangtsiao Jul 8, 2026
573ba8f
feat(provider): classify stream errors as retryable with ProviderErro…
wangtsiao Jul 8, 2026
889ac47
feat(core/tools): cancel hanging tool execution via CancellationToken
wangtsiao Jul 8, 2026
26ea161
feat(core,server): persist locked session context via SessionContextU…
wangtsiao Jul 8, 2026
1896332
feat(server): wire provider retry status through turn event stream an…
wangtsiao Jul 8, 2026
239c7a3
feat(server): manage session context recording and lifecycle updates …
wangtsiao Jul 8, 2026
21c244c
feat(server/tests): add persistence resume and goal interrupt lifecyc…
wangtsiao Jul 8, 2026
30ab886
feat(tui): display provider retry status in worker events
wangtsiao Jul 8, 2026
200894e
feat(desktop/ai-sdk): handle provider retry status event and pass tur…
wangtsiao Jul 8, 2026
cca33dc
feat(desktop): add retryStatus state management and event processing …
wangtsiao Jul 8, 2026
d024704
feat(desktop): propagate turnId to ChatTurn and display retry status …
wangtsiao Jul 8, 2026
e32ceda
feat(protocol): add last_query_usage to SessionMetadata for structure…
wangtsiao Jul 8, 2026
839929a
feat(server): track latest_query_usage in replay state for structured…
wangtsiao Jul 8, 2026
aefca94
fix(desktop/ai-sdk): handle nested session objects in compaction even…
wangtsiao Jul 8, 2026
4b6c37f
feat(tui): block user input during session resume with status indicator
wangtsiao Jul 8, 2026
d99abf7
feat(tui): add restored session display, history browsing, and event …
wangtsiao Jul 8, 2026
06804ac
feat(tui): add working tip visibility toggle to status indicator
wangtsiao Jul 8, 2026
05372c4
feat(tui): cover restored session, history, and status indicator in w…
wangtsiao Jul 8, 2026
ec72f12
test(desktop): verify bridge runtime compaction event source includes…
wangtsiao Jul 8, 2026
48ec742
feat(server): wire last_query_usage through turn lifecycle stages
wangtsiao Jul 8, 2026
2de6920
test(server): verify last_query_usage in resumed session after persis…
wangtsiao Jul 8, 2026
8660f94
fix(tui): propagate last_query_tokens through SessionCompacted event …
wangtsiao Jul 8, 2026
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,9 @@ Open an issue or pull request to discuss changes.

<a href="https://www.star-history.com/?repos=7df-lab%2Fdevo&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=7df-lab/devo&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=7df-lab/devo&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=7df-lab/devo&type=date&legend=top-left" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=7df-lab/devo&type=date&theme=dark&legend=top-left&sealed_token=MnUnrvz2y0jVX7ra8V0X5Z6AvrYDpTndpzZkHP-0ilm9g4EpwuNEvvmJfE8I2qMI8tDhAkn5a2T5dks7vIrGvP1eVw3ov01-m4_l7zJyWuuAFP-f4MSHSvyRJVhkhWmoioiwBNutxCcNuvDtZkufcwHISOkZglpGH7tZhIZDj-ITupTBDzZmBVfI-_DU" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=7df-lab/devo&type=date&legend=top-left&sealed_token=MnUnrvz2y0jVX7ra8V0X5Z6AvrYDpTndpzZkHP-0ilm9g4EpwuNEvvmJfE8I2qMI8tDhAkn5a2T5dks7vIrGvP1eVw3ov01-m4_l7zJyWuuAFP-f4MSHSvyRJVhkhWmoioiwBNutxCcNuvDtZkufcwHISOkZglpGH7tZhIZDj-ITupTBDzZmBVfI-_DU" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=7df-lab/devo&type=date&legend=top-left&sealed_token=MnUnrvz2y0jVX7ra8V0X5Z6AvrYDpTndpzZkHP-0ilm9g4EpwuNEvvmJfE8I2qMI8tDhAkn5a2T5dks7vIrGvP1eVw3ov01-m4_l7zJyWuuAFP-f4MSHSvyRJVhkhWmoioiwBNutxCcNuvDtZkufcwHISOkZglpGH7tZhIZDj-ITupTBDzZmBVfI-_DU" />
</picture>
</a>

Expand Down
67 changes: 67 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 @@ -1731,6 +1731,73 @@ describe("ACP desktop SDK session mapping", () => {
expect(await client.session.status()).toEqual({ data: { s1: { type: "idle" } } })
})

test("emits compaction lifecycle events from wrapped original events with nested session metadata", 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/originalMethod": "session/compaction/started",
"devo/originalEvent": {
kind: "session_compaction_started",
session: {
session_id: "s1",
title: "Existing session",
},
},
},
} satisfies AcpSessionNotification)
transport.emitSessionUpdate({
sessionId: "s1",
update: { sessionUpdate: "session_info_update" },
_meta: {
"devo/originalMethod": "session/compaction/completed",
"devo/originalEvent": {
SessionCompactionCompleted: {
session: {
sessionId: "s1",
title: "Existing session",
},
},
},
},
} satisfies AcpSessionNotification)
transport.emitSessionUpdate({
sessionId: "s1",
update: { sessionUpdate: "session_info_update" },
_meta: {
"devo/originalMethod": "session/compaction/failed",
"devo/originalEvent": {
SessionCompactionFailed: {
session_id: "s1",
message: "provider unavailable",
},
},
},
} satisfies AcpSessionNotification)

expect(await nextPayloadOfType(stream, "session.compaction.started", "started")).toEqual({
type: "session.compaction.started",
properties: { sessionID: "s1" },
})
expect(await nextPayloadOfType(stream, "session.compaction.completed", "completed")).toEqual({
type: "session.compaction.completed",
properties: { sessionID: "s1" },
})
expect(await nextPayloadOfType(stream, "session.compaction.failed", "failed")).toEqual({
type: "session.compaction.failed",
properties: { sessionID: "s1", message: "provider unavailable" },
})
})

test("refreshes session status from session info update metadata", async () => {
const transport = new FakeTransport((method) => {
if (method === "initialize") return initializeResult
Expand Down
47 changes: 45 additions & 2 deletions apps/desktop/packages/devo-ai-sdk/src/v2/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,29 @@ function sessionMeta(value: unknown): Record<string, unknown> | undefined {
return objectRecord(meta?.["devo/session"])
}

function providerRetryStatusFromOriginalEvent(
original: Record<string, unknown>,
originalMethod?: string,
): Record<string, unknown> | null {
if (originalMethod !== "turn/provider_retry_status" && !("TurnProviderRetryStatus" in original) && original.kind !== "turn_provider_retry_status") {
return null
}
const payload = objectRecord(original.TurnProviderRetryStatus) ?? original
const sessionID = String(payload.session_id ?? payload.sessionId ?? "")
const turnID = String(payload.turn_id ?? payload.turnId ?? "")
if (!sessionID || !turnID) return null
return {
sessionID,
turnID,
attempt: numberFromProtocol(payload.attempt),
backoffMs: numberFromProtocol(payload.backoff_ms ?? payload.backoffMs),
provider: String(payload.provider ?? ""),
model: String(payload.model ?? ""),
phase: String(payload.phase ?? ""),
message: String(payload.message ?? ""),
}
}

function sessionStatusFromMetadata(value: unknown): string | undefined {
const meta = objectRecord(value)
const nestedStatus = objectRecord(meta?.["devo/session"])?.status
Expand Down Expand Up @@ -320,6 +343,14 @@ function sessionStatusChangedFromOriginalEvent(
return typeof sessionId === "string" && typeof status === "string" ? { sessionId, status } : null
}

function sessionIdFromCompactionPayload(payload: Record<string, unknown>): string | null {
const direct = payload.session_id ?? payload.sessionId
if (typeof direct === "string" && direct) return direct
const session = objectRecord(payload.session)
const nested = session?.session_id ?? session?.sessionId
return typeof nested === "string" && nested ? nested : null
}

function sessionCompactionFromOriginalEvent(
original: unknown,
originalMethod?: string,
Expand Down Expand Up @@ -369,8 +400,8 @@ function sessionCompactionFromOriginalEvent(
}

if (!status || !payload) return null
const sessionId = payload.session_id ?? payload.sessionId
if (typeof sessionId !== "string" || !sessionId) return null
const sessionId = sessionIdFromCompactionPayload(payload)
if (!sessionId) return null
const message = payload.message
return {
sessionId,
Expand Down Expand Up @@ -1525,6 +1556,14 @@ class AcpClient {
this.handleDeletedSessionIds(deletedSessionIds, directory)
return
}
const retryStatus = providerRetryStatusFromOriginalEvent(original as Record<string, unknown>, originalMethod)
if (retryStatus) {
this.emit(directory, {
type: "turn.provider_retry_status",
properties: retryStatus,
})
return
}
const changedStatus = sessionStatusChangedFromOriginalEvent(original, originalMethod)
if (changedStatus) {
this.rememberSessionStatus(changedStatus.sessionId, directory, changedStatus.status)
Expand Down Expand Up @@ -1796,12 +1835,14 @@ class AcpClient {
existingMessage,
now,
)
const turnId = this.turnIdForUpdate(update)
const message = {
...(existingMessage ?? {}),
id: messageId,
sessionID: sessionId,
role,
...(parentID ? { parentID } : {}),
...(turnId ? { turnID: turnId } : {}),
time: { ...(existingMessage?.time ?? {}), created },
} as Message
this.appendMessage(sessionId, message)
Expand Down Expand Up @@ -1865,12 +1906,14 @@ class AcpClient {
existingMessage,
now,
)
const turnId = this.turnIdForUpdate(update)
const message = {
...(existingMessage ?? {}),
id: messageId,
sessionID: sessionId,
role: "assistant",
...(parentID ? { parentID } : {}),
...(turnId ? { turnID: turnId } : {}),
time: { ...(existingMessage?.time ?? {}), created },
} as Message
const partEventTime = updateHistoryCreatedAt(update) ?? now
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/renderer/atoms/actions/event-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
removePermissionAtom,
removeQuestionAtom,
removeSessionAtom,
setProviderRetryStatusAtom,
setSessionErrorAtom,
setSessionStatusAtom,
upsertSessionAtom,
Expand Down Expand Up @@ -102,6 +103,31 @@ export function processEvent(event: Event): void {
set(removeSessionAtom, event.properties.info.id)
break

case "turn.provider_retry_status": {
const properties = event.properties
const sessionId = properties.sessionID ?? properties.session_id
const turnId = properties.turnID ?? properties.turn_id
if (sessionId && turnId) {
const phase = String(properties.phase ?? "")
set(setProviderRetryStatusAtom, {
sessionId,
status:
phase === "resumed"
? undefined
: {
turnId,
attempt: Number(properties.attempt ?? 0),
backoffMs: Number(properties.backoffMs ?? properties.backoff_ms ?? 0),
provider: String(properties.provider ?? ""),
model: String(properties.model ?? ""),
phase,
message: String(properties.message ?? ""),
},
})
}
break
}

case "session.status":
set(setSessionStatusAtom, {
sessionId: event.properties.sessionID,
Expand Down
27 changes: 26 additions & 1 deletion apps/desktop/src/renderer/atoms/derived/session-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,34 @@ export interface ChatMessageEntry {
*/
export interface ChatTurn {
id: string
/** Protocol turn id when available (`Message.turnID` / `devo/turnId`). */
turnId?: string
userMessage: ChatMessageEntry
assistantMessages: ChatMessageEntry[]
}

function messageProtocolTurnId(entry: ChatMessageEntry): string | undefined {
const info = entry.info as {
turnID?: unknown
turnId?: unknown
metadata?: Record<string, unknown>
}
const direct = info.turnID ?? info.turnId
if (typeof direct === "string" && direct.length > 0) return direct
const metaTurnId = info.metadata?.["devo/turnId"]
return typeof metaTurnId === "string" && metaTurnId.length > 0 ? metaTurnId : undefined
}

function protocolTurnIdForTurn(
userMessage: ChatMessageEntry,
assistantMessages: ChatMessageEntry[],
): string | undefined {
return (
messageProtocolTurnId(userMessage) ??
assistantMessages.map(messageProtocolTurnId).find((turnId): turnId is string => Boolean(turnId))
)
}

// ============================================================
// Turn grouping with structural sharing
// ============================================================
Expand Down Expand Up @@ -59,7 +83,7 @@ function messageFingerprint(entry: ChatMessageEntry): string {

function turnFingerprint(turn: ChatTurn): string {
const assistantFps = turn.assistantMessages.map(messageFingerprint).join("|")
return `${messageFingerprint(turn.userMessage)}>${assistantFps}`
return `${turn.turnId ?? ""}:${messageFingerprint(turn.userMessage)}>${assistantFps}`
}

export function groupIntoTurns(entries: ChatMessageEntry[], prevTurns: ChatTurn[]): ChatTurn[] {
Expand All @@ -76,6 +100,7 @@ export function groupIntoTurns(entries: ChatMessageEntry[], prevTurns: ChatTurn[
if (!currentUser) return
const newTurn: ChatTurn = {
id: currentUser.info.id,
turnId: protocolTurnIdForTurn(currentUser, currentAssistantMessages),
userMessage: currentUser,
assistantMessages: currentAssistantMessages,
}
Expand Down
39 changes: 39 additions & 0 deletions apps/desktop/src/renderer/atoms/messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,41 @@ describe("message ordering", () => {
expect(groupIntoTurns(entries, [])).toEqual([
{
id: user.id,
turnId: undefined,
userMessage: { info: user, parts: [] },
assistantMessages: [{ info: assistant, parts: [] }],
},
])
})

test("propagates protocol turn ids onto chat turns", () => {
const user = {
id: "u1",
sessionID: "s1",
role: "user",
turnID: "protocol-turn-1",
time: { created: 1 },
}
const assistant = {
id: "a1",
sessionID: "s1",
role: "assistant",
parentID: "u1",
turnID: "protocol-turn-1",
time: { created: 2 },
}
const turns = groupIntoTurns(
[
{ info: user, parts: [] },
{ info: assistant, parts: [] },
],
[],
)

expect(turns).toEqual([
{
id: "u1",
turnId: "protocol-turn-1",
userMessage: { info: user, parts: [] },
assistantMessages: [{ info: assistant, parts: [] }],
},
Expand Down Expand Up @@ -148,11 +183,13 @@ describe("message ordering", () => {
expect(groupIntoTurns(entries, [])).toEqual([
{
id: "u1",
turnId: undefined,
userMessage: { info: firstUser, parts: [] },
assistantMessages: [{ info: firstAssistant, parts: [] }],
},
{
id: "u2",
turnId: undefined,
userMessage: { info: secondUser, parts: [] },
assistantMessages: [{ info: secondAssistant, parts: [] }],
},
Expand Down Expand Up @@ -230,6 +267,7 @@ describe("message ordering", () => {
expect(groupIntoTurns(entries, [])).toEqual([
{
id: "u1",
turnId: undefined,
userMessage: { info: firstUser, parts: [] },
assistantMessages: [
{ info: firstTool, parts: [firstToolPart] },
Expand All @@ -238,6 +276,7 @@ describe("message ordering", () => {
},
{
id: "u2",
turnId: undefined,
userMessage: { info: secondUser, parts: [] },
assistantMessages: [
{ info: secondTool, parts: [secondToolPart] },
Expand Down
Loading
Loading