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
94 changes: 94 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 @@ -1844,6 +1844,100 @@ describe("ACP desktop SDK session mapping", () => {
})
})

test("bridges turn context-compaction items into lifecycle events and a transcript marker", async () => {
Date.now = () => 1_772_000_000_000
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()
const context = {
session_id: "s1",
turn_id: "turn-1",
item_id: "compact-1",
seq: 9,
}
const item = {
item_id: "compact-1",
item_kind: "context_compaction",
payload: { title: "Compaction started" },
}
transport.emitSessionUpdate({
sessionId: "s1",
update: { sessionUpdate: "session_info_update" },
_meta: {
"devo/originalMethod": "item/started",
"devo/originalEvent": { kind: "item_started", context, item },
},
} satisfies AcpSessionNotification)

expect(await nextPayloadOfType(stream, "session.compaction.started", "item started")).toEqual({
type: "session.compaction.started",
properties: { sessionID: "s1" },
})
expect(await nextPayloadOfType(stream, "message.part.updated", "compaction marker")).toEqual({
type: "message.part.updated",
properties: {
part: {
id: "compaction-compact-1-text",
sessionID: "s1",
messageID: "compaction-compact-1",
type: "text",
text: "Session compaction started.",
time: { start: 1_772_000_000_000 },
},
},
})

transport.emitSessionUpdate({
sessionId: "s1",
update: { sessionUpdate: "session_info_update" },
_meta: {
"devo/originalMethod": "item/completed",
"devo/originalEvent": {
kind: "item_completed",
context,
item: { ...item, payload: { title: "Context compacted" } },
},
},
} satisfies AcpSessionNotification)

expect(await nextPayloadOfType(stream, "session.compaction.completed", "item completed")).toEqual({
type: "session.compaction.completed",
properties: { sessionID: "s1" },
})

transport.emitSessionUpdate({
sessionId: "s1",
update: { sessionUpdate: "session_info_update" },
_meta: {
"devo/originalMethod": "item/completed",
"devo/originalEvent": {
kind: "item_completed",
context: { ...context, item_id: "compact-2" },
item: {
...item,
item_id: "compact-2",
payload: {
title: "Compaction failed",
status: "failed",
message: "summary unavailable",
},
},
},
},
} satisfies AcpSessionNotification)

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

test("refreshes session status from session info update metadata", async () => {
const transport = new FakeTransport((method) => {
if (method === "initialize") return initializeResult
Expand Down
52 changes: 49 additions & 3 deletions apps/desktop/packages/devo-ai-sdk/src/v2/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,9 @@ function sessionStatusChangedFromOriginalEvent(
function sessionIdFromCompactionPayload(payload: Record<string, unknown>): string | null {
const direct = payload.session_id ?? payload.sessionId
if (typeof direct === "string" && direct) return direct
const context = objectRecord(payload.context)
const contextual = context?.session_id ?? context?.sessionId
if (typeof contextual === "string" && contextual) return contextual
const session = objectRecord(payload.session)
const nested = session?.session_id ?? session?.sessionId
return typeof nested === "string" && nested ? nested : null
Expand All @@ -395,13 +398,38 @@ function sessionIdFromCompactionPayload(payload: Record<string, unknown>): strin
function sessionCompactionFromOriginalEvent(
original: unknown,
originalMethod?: string,
): { sessionId: string; status: "started" | "completed" | "failed"; message?: string } | null {
): {
sessionId: string
status: "started" | "completed" | "failed"
message?: string
itemId?: string
turnId?: string
} | null {
const event = objectRecord(original)
if (!event) return null

let status: "started" | "completed" | "failed" | null = null
let payload: Record<string, unknown> | undefined
if (originalMethod === "session/compaction/started") {
let itemId: string | undefined
let turnId: string | undefined
if (originalMethod === "item/started" || originalMethod === "item/completed") {
const item = objectRecord(event.item)
if (item?.item_kind !== "context_compaction" && item?.itemKind !== "context_compaction") {
return null
}
const context = objectRecord(event.context)
const itemPayload = objectRecord(item.payload)
status = originalMethod === "item/started"
? "started"
: itemPayload?.status === "failed"
? "failed"
: "completed"
payload = event
const rawItemId = item.item_id ?? item.itemId
const rawTurnId = context?.turn_id ?? context?.turnId
itemId = typeof rawItemId === "string" && rawItemId ? rawItemId : undefined
turnId = typeof rawTurnId === "string" && rawTurnId ? rawTurnId : undefined
} else if (originalMethod === "session/compaction/started") {
status = "started"
payload = objectRecord(event.SessionCompactionStarted) ?? event
} else if (originalMethod === "session/compaction/completed") {
Expand Down Expand Up @@ -443,11 +471,14 @@ function sessionCompactionFromOriginalEvent(
if (!status || !payload) return null
const sessionId = sessionIdFromCompactionPayload(payload)
if (!sessionId) return null
const message = payload.message
const itemPayload = objectRecord(objectRecord(payload.item)?.payload)
const message = payload.message ?? itemPayload?.message
return {
sessionId,
status,
...(typeof message === "string" && message ? { message } : {}),
...(itemId ? { itemId } : {}),
...(turnId ? { turnId } : {}),
}
}

Expand Down Expand Up @@ -1646,6 +1677,21 @@ class AcpClient {
...(compaction.message ? { message: compaction.message } : {}),
},
})
if (compaction.status === "started" && compaction.itemId) {
const messageId = `compaction-${compaction.itemId}`
const markerExists = this.parts
.get(partCacheKey(sessionId, messageId))
?.some((part) => part.type === "text" && part.text === "Session compaction started.")
if (!markerExists) {
this.appendText(sessionId, directory, "assistant", "text", {
content: { text: "Session compaction started." },
messageId,
...(compaction.turnId
? { _meta: { [DEVO_TURN_ID_META]: compaction.turnId } }
: {}),
})
}
}
return
}
const payload = requestUserInputFromOriginalEvent(original)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ describe("ChatTurnComponent transcript controls", () => {
compactionDividerSource.includes("BubblesIcon") &&
compactionDividerSource.includes("PackageCheckIcon"),
usesRequestedLabels:
compactionDividerSource.includes("Compacting context") &&
compactionDividerSource.includes("Compaction started") &&
compactionDividerSource.includes("Context compacted"),
keepsIconStyleConsistent:
compactionDividerSource.includes("size-3.5") &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function CompactionStatusDivider({
}) {
const isCompleted = status === "completed";
const Icon = isCompleted ? PackageCheckIcon : BubblesIcon;
const label = isCompleted ? "Context compacted" : "Compacting context";
const label = isCompleted ? "Context compacted" : "Compaction started";

return (
<div
Expand Down
2 changes: 1 addition & 1 deletion crates/cli/src/agent_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub(crate) async fn run_agent(
.iter()
.map(|warning| {
format!(
"Skipped model catalog override {}: {}",
"Model catalog warning for {}: {}",
warning.path.display(),
warning.message
)
Expand Down
59 changes: 58 additions & 1 deletion crates/cli/src/prompt_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,15 @@ enum PromptJsonlEvent<'a> {
phase: &'static str,
message: &'a str,
},
#[serde(rename = "session.compaction.started")]
ContextCompactionStarted { session_id: &'a str },
#[serde(rename = "session.compaction.completed")]
ContextCompactionCompleted { session_id: &'a str },
#[serde(rename = "session.compaction.failed")]
ContextCompactionFailed {
session_id: &'a str,
message: &'a str,
},
#[serde(rename = "turn.usage_delta")]
UsageDelta {
session_id: &'a str,
Expand Down Expand Up @@ -473,6 +482,18 @@ fn write_query_event_jsonl(session_id: &str, event: &QueryEvent) -> Result<()> {
message: status.message.as_str(),
})
}
QueryEvent::ContextCompactionStarted => {
write_jsonl(&PromptJsonlEvent::ContextCompactionStarted { session_id })
}
QueryEvent::ContextCompactionCompleted => {
write_jsonl(&PromptJsonlEvent::ContextCompactionCompleted { session_id })
}
QueryEvent::ContextCompactionFailed { message } => {
write_jsonl(&PromptJsonlEvent::ContextCompactionFailed {
session_id,
message,
})
}
QueryEvent::UsageDelta { usage } => write_jsonl(&PromptJsonlEvent::UsageDelta {
session_id,
usage: PromptUsageDelta::new(usage),
Expand Down Expand Up @@ -586,7 +607,7 @@ mod tests {
use pretty_assertions::assert_eq;

use super::latest_assistant_text;
use super::{PromptResult, PromptUsage};
use super::{PromptJsonlEvent, PromptResult, PromptUsage};
use devo_core::ContentBlock;
use devo_core::Message;
use devo_core::Role;
Expand Down Expand Up @@ -628,6 +649,42 @@ mod tests {
);
}

#[test]
fn prompt_jsonl_compaction_lifecycle_uses_session_event_shapes() {
assert_eq!(
serde_json::to_value(PromptJsonlEvent::ContextCompactionStarted {
session_id: "session-1",
})
.expect("serialize compaction started"),
serde_json::json!({
"type": "session.compaction.started",
"session_id": "session-1"
})
);
assert_eq!(
serde_json::to_value(PromptJsonlEvent::ContextCompactionCompleted {
session_id: "session-1",
})
.expect("serialize compaction completed"),
serde_json::json!({
"type": "session.compaction.completed",
"session_id": "session-1"
})
);
assert_eq!(
serde_json::to_value(PromptJsonlEvent::ContextCompactionFailed {
session_id: "session-1",
message: "compaction provider failed",
})
.expect("serialize compaction failed"),
serde_json::json!({
"type": "session.compaction.failed",
"session_id": "session-1",
"message": "compaction provider failed"
})
);
}

#[test]
fn latest_assistant_text_returns_none_for_empty_messages() {
assert_eq!(latest_assistant_text(&[]), None);
Expand Down
7 changes: 7 additions & 0 deletions crates/config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,13 @@ defaults, merged by `slug`. Turn metadata records `model` as the catalog slug
and `request_model` as the provider request model; these values may be
identical.

Untouched built-in entries in `<DEVO_HOME>/models.json` are synchronized with
the catalog bundled in the running binary. Devo records the source fingerprint
and update policy in a reserved `_devo` object. Editing model fields preserves
the full entry as a user override. Set `_devo.update_policy` to `"pinned"` to
prevent an unchanged built-in entry from being refreshed explicitly. Custom
slugs and workspace-level catalogs are never rewritten by this synchronization.

When reasoning effort resolution selects a model variant catalog slug, the provider
request model is resolved from enabled bindings for the same provider as the
selected turn binding. Duplicate `model_slug` values under other providers do
Expand Down
2 changes: 1 addition & 1 deletion crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ serde_json = { workspace = true }
sha2 = { workspace = true }
shlex = { workspace = true }
smol_str = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-stream = { workspace = true }
Expand All @@ -60,6 +61,5 @@ devo-safety = { workspace = true }
futures = { workspace = true }
pretty_assertions = { workspace = true }
serde_json = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["full"] }
uuid = { workspace = true }
8 changes: 4 additions & 4 deletions crates/core/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@
"high",
"max"
],
"context_window": 1000000,
"max_tokens": 384000,
"context_window": 1048565,
"max_tokens": 8096,
"effective_context_window_percent": 95,
"truncation_policy": {
"mode": "tokens",
Expand All @@ -201,8 +201,8 @@
"high",
"max"
],
"context_window": 1000000,
"max_tokens": 384000,
"context_window": 1048565,
"max_tokens": 8096,
"effective_context_window_percent": 95,
"truncation_policy": {
"mode": "tokens",
Expand Down
Loading
Loading