Skip to content

Commit 25e4839

Browse files
committed
fix: consistent turn.completed signal and OpenCode session.idle timing
1 parent 85a7532 commit 25e4839

File tree

6 files changed

+171
-35
lines changed

6 files changed

+171
-35
lines changed

server/packages/sandbox-agent/src/opencode_compat.rs

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1410,6 +1410,25 @@ async fn apply_universal_event(state: Arc<OpenCodeAppState>, event: UniversalEve
14101410
match event.event_type {
14111411
UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => {
14121412
if let UniversalEventData::Item(ItemEventData { item }) = &event.data {
1413+
// turn.completed or session.idle status → emit session.idle
1414+
if event.event_type == UniversalEventType::ItemCompleted
1415+
&& item.kind == ItemKind::Status
1416+
{
1417+
if let Some(ContentPart::Status { label, .. }) = item.content.first() {
1418+
if label == "turn.completed" || label == "session.idle" {
1419+
let session_id = event.session_id.clone();
1420+
state.opencode.emit_event(json!({
1421+
"type": "session.status",
1422+
"properties": {"sessionID": session_id, "status": {"type": "idle"}}
1423+
}));
1424+
state.opencode.emit_event(json!({
1425+
"type": "session.idle",
1426+
"properties": {"sessionID": session_id}
1427+
}));
1428+
return;
1429+
}
1430+
}
1431+
}
14131432
apply_item_event(state, event.clone(), item.clone()).await;
14141433
}
14151434
}
@@ -1894,19 +1913,6 @@ async fn apply_item_event(
18941913
}
18951914
}
18961915

1897-
if event.event_type == UniversalEventType::ItemCompleted {
1898-
state.opencode.emit_event(json!({
1899-
"type": "session.status",
1900-
"properties": {
1901-
"sessionID": session_id,
1902-
"status": {"type": "idle"}
1903-
}
1904-
}));
1905-
state.opencode.emit_event(json!({
1906-
"type": "session.idle",
1907-
"properties": { "sessionID": session_id }
1908-
}));
1909-
}
19101916
}
19111917

19121918
async fn apply_tool_item_event(

server/packages/sandbox-agent/src/router.rs

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ use reqwest::Client;
2222
use sandbox_agent_error::{AgentError, ErrorType, ProblemDetails, SandboxError};
2323
use sandbox_agent_universal_agent_schema::{
2424
codex as codex_schema, convert_amp, convert_claude, convert_codex, convert_opencode,
25-
AgentUnparsedData, ContentPart, ErrorData, EventConversion, EventSource, FileAction,
26-
ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, PermissionEventData,
25+
turn_completed_event, AgentUnparsedData, ContentPart, ErrorData, EventConversion, EventSource,
26+
FileAction, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus, PermissionEventData,
2727
PermissionStatus, QuestionEventData, QuestionStatus, ReasoningVisibility, SessionEndReason,
2828
SessionEndedData, SessionStartedData, StderrOutput, TerminatedBy, UniversalEvent,
2929
UniversalEventData, UniversalEventType, UniversalItem,
@@ -6015,7 +6015,26 @@ fn mock_command_conversions(prefix: &str, input: &str) -> Vec<EventConversion> {
60156015
if trimmed.is_empty() {
60166016
return vec![];
60176017
}
6018+
let mut events = mock_command_events(prefix, trimmed);
6019+
if should_append_turn_completed(&events) {
6020+
events.push(turn_completed_event());
6021+
}
6022+
events
6023+
}
6024+
6025+
fn should_append_turn_completed(events: &[EventConversion]) -> bool {
6026+
let Some(last) = events.last() else {
6027+
return false;
6028+
};
6029+
!matches!(
6030+
last.event_type,
6031+
UniversalEventType::SessionEnded
6032+
| UniversalEventType::PermissionRequested
6033+
| UniversalEventType::QuestionRequested
6034+
)
6035+
}
60186036

6037+
fn mock_command_events(prefix: &str, trimmed: &str) -> Vec<EventConversion> {
60196038
if trimmed.eq_ignore_ascii_case(MOCK_OK_PROMPT) {
60206039
return mock_assistant_message(format!("{prefix}_ok"), "OK".to_string());
60216040
}
@@ -6850,7 +6869,7 @@ fn stream_turn_events(
68506869
})
68516870
}
68526871

6853-
fn is_turn_terminal(event: &UniversalEvent, agent: AgentId) -> bool {
6872+
fn is_turn_terminal(event: &UniversalEvent, _agent: AgentId) -> bool {
68546873
match event.event_type {
68556874
UniversalEventType::SessionEnded
68566875
| UniversalEventType::Error
@@ -6861,15 +6880,7 @@ fn is_turn_terminal(event: &UniversalEvent, agent: AgentId) -> bool {
68616880
let UniversalEventData::Item(ItemEventData { item }) = &event.data else {
68626881
return false;
68636882
};
6864-
if let Some(label) = status_label(item) {
6865-
if label == "turn.completed" || label == "session.idle" {
6866-
return true;
6867-
}
6868-
}
6869-
if matches!(item.role, Some(ItemRole::Assistant)) && item.kind == ItemKind::Message {
6870-
return agent != AgentId::Codex;
6871-
}
6872-
false
6883+
matches!(status_label(item), Some("turn.completed" | "session.idle"))
68736884
}
68746885
_ => false,
68756886
}

server/packages/sandbox-agent/tests/opencode-compat/events.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,98 @@ describe("OpenCode-compatible Event Streaming", () => {
145145
expect(response.data).toBeDefined();
146146
});
147147
});
148+
149+
describe("session.idle count", () => {
150+
it("should emit exactly one session.idle for echo flow", async () => {
151+
const session = await client.session.create();
152+
const sessionId = session.data?.id!;
153+
154+
const eventStream = await client.event.subscribe();
155+
const idleEvents: any[] = [];
156+
157+
// Wait for first idle, then linger 1s for duplicates
158+
const collectIdle = new Promise<void>((resolve, reject) => {
159+
let lingerTimer: ReturnType<typeof setTimeout> | null = null;
160+
const timeout = setTimeout(() => reject(new Error("Timed out waiting for session.idle")), 15_000);
161+
(async () => {
162+
try {
163+
for await (const event of (eventStream as any).stream) {
164+
if (event.type === "session.idle") {
165+
idleEvents.push(event);
166+
if (!lingerTimer) {
167+
lingerTimer = setTimeout(() => {
168+
clearTimeout(timeout);
169+
resolve();
170+
}, 1000);
171+
}
172+
}
173+
}
174+
} catch {
175+
// Stream ended
176+
}
177+
})();
178+
});
179+
180+
await client.session.prompt({
181+
path: { id: sessionId },
182+
body: {
183+
model: { providerID: "sandbox-agent", modelID: "mock" },
184+
parts: [{ type: "text", text: "echo hello" }],
185+
},
186+
});
187+
188+
await collectIdle;
189+
expect(idleEvents.length).toBe(1);
190+
});
191+
192+
it("should emit exactly one session.idle for tool flow", async () => {
193+
const session = await client.session.create();
194+
const sessionId = session.data?.id!;
195+
196+
const eventStream = await client.event.subscribe();
197+
const allEvents: any[] = [];
198+
const idleEvents: any[] = [];
199+
200+
const collectIdle = new Promise<void>((resolve, reject) => {
201+
let lingerTimer: ReturnType<typeof setTimeout> | null = null;
202+
const timeout = setTimeout(() => reject(new Error("Timed out waiting for session.idle")), 15_000);
203+
(async () => {
204+
try {
205+
for await (const event of (eventStream as any).stream) {
206+
allEvents.push(event);
207+
if (event.type === "session.idle") {
208+
idleEvents.push(event);
209+
if (!lingerTimer) {
210+
lingerTimer = setTimeout(() => {
211+
clearTimeout(timeout);
212+
resolve();
213+
}, 1000);
214+
}
215+
}
216+
}
217+
} catch {
218+
// Stream ended
219+
}
220+
})();
221+
});
222+
223+
await client.session.prompt({
224+
path: { id: sessionId },
225+
body: {
226+
model: { providerID: "sandbox-agent", modelID: "mock" },
227+
parts: [{ type: "text", text: "tool" }],
228+
},
229+
});
230+
231+
await collectIdle;
232+
233+
expect(idleEvents.length).toBe(1);
234+
235+
// All tool parts should have been emitted before idle
236+
const toolParts = allEvents.filter(
237+
(e) => e.type === "message.part.updated" && e.properties?.part?.type === "tool"
238+
);
239+
expect(toolParts.length).toBeGreaterThan(0);
240+
});
241+
});
148242
});

server/packages/universal-agent-schema/src/agents/amp.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ use serde_json::Value;
44

55
use crate::amp as schema;
66
use crate::{
7-
ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole,
8-
ItemStatus, SessionEndReason, SessionEndedData, TerminatedBy, UniversalEventData,
9-
UniversalEventType, UniversalItem,
7+
turn_completed_event, ContentPart, ErrorData, EventConversion, ItemDeltaData, ItemEventData,
8+
ItemKind, ItemRole, ItemStatus, SessionEndReason, SessionEndedData, TerminatedBy,
9+
UniversalEventData, UniversalEventType, UniversalItem,
1010
};
1111

1212
static TEMP_ID: AtomicU64 = AtomicU64::new(1);
@@ -99,6 +99,7 @@ pub fn event_to_universal(
9999
));
100100
}
101101
schema::StreamJsonMessageType::Done => {
102+
events.push(turn_completed_event());
102103
events.push(
103104
EventConversion::new(
104105
UniversalEventType::SessionEnded,

server/packages/universal-agent-schema/src/agents/claude.rs

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ use std::sync::atomic::{AtomicU64, Ordering};
33
use serde_json::Value;
44

55
use crate::{
6-
ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind, ItemRole, ItemStatus,
7-
PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus, SessionStartedData,
8-
UniversalEventData, UniversalEventType, UniversalItem,
6+
turn_completed_event, ContentPart, EventConversion, ItemDeltaData, ItemEventData, ItemKind,
7+
ItemRole, ItemStatus, PermissionEventData, PermissionStatus, QuestionEventData, QuestionStatus,
8+
SessionStartedData, UniversalEventData, UniversalEventType, UniversalItem,
99
};
1010

1111
static TEMP_ID: AtomicU64 = AtomicU64::new(1);
@@ -420,10 +420,13 @@ fn result_event_to_universal(event: &Value, session_id: &str) -> Vec<EventConver
420420
status: ItemStatus::Completed,
421421
};
422422

423-
vec![EventConversion::new(
424-
UniversalEventType::ItemCompleted,
425-
UniversalEventData::Item(ItemEventData { item: message_item }),
426-
)]
423+
vec![
424+
EventConversion::new(
425+
UniversalEventType::ItemCompleted,
426+
UniversalEventData::Item(ItemEventData { item: message_item }),
427+
),
428+
turn_completed_event(),
429+
]
427430
}
428431

429432
fn claude_message_id(event: &Value, session_id: &str) -> String {

server/packages/universal-agent-schema/src/lib.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,27 @@ impl EventConversion {
317317
}
318318
}
319319

320+
pub fn turn_completed_event() -> EventConversion {
321+
EventConversion::new(
322+
UniversalEventType::ItemCompleted,
323+
UniversalEventData::Item(ItemEventData {
324+
item: UniversalItem {
325+
item_id: String::new(),
326+
native_item_id: None,
327+
parent_id: None,
328+
kind: ItemKind::Status,
329+
role: Some(ItemRole::System),
330+
content: vec![ContentPart::Status {
331+
label: "turn.completed".to_string(),
332+
detail: None,
333+
}],
334+
status: ItemStatus::Completed,
335+
},
336+
}),
337+
)
338+
.synthetic()
339+
}
340+
320341
pub fn item_from_text(role: ItemRole, text: String) -> UniversalItem {
321342
UniversalItem {
322343
item_id: String::new(),

0 commit comments

Comments
 (0)