Skip to content

Commit 5d3fcf5

Browse files
committed
Limit agent mode list to primary
1 parent f6a37fd commit 5d3fcf5

File tree

7 files changed

+304
-21
lines changed

7 files changed

+304
-21
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,5 +180,6 @@ Use extra care in high-churn/high-complexity files:
180180
- Setup/build/release/test commands: `README.md`
181181
- Architecture decision (ACP to REST migration): `docs/shaping/rest-api-migration.md`
182182
- Frontend event contract: `docs/app-server-events.md`
183+
- For OpenCode API/feature changes, refer to `opencode-server-api.mdx` and `./tmp/opencode` before implementing protocol or behavior updates.
183184

184185
##

src-tauri/src/backend/event_translator.rs

Lines changed: 146 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,14 @@ struct PerSessionTurnState {
2222
agent_message_item_id: Option<String>,
2323
/// OpenCode part ID for the current text part (used to handle part removals/reset).
2424
agent_message_part_id: Option<String>,
25+
/// Number of bytes already emitted for the current agent text part.
26+
agent_message_text_len: usize,
2527
/// Stable item ID for the current reasoning stream.
2628
reasoning_item_id: Option<String>,
2729
/// OpenCode part ID for the current reasoning part (used to route `message.part.delta` events).
2830
reasoning_part_id: Option<String>,
31+
/// Number of bytes already emitted for the current reasoning part.
32+
reasoning_text_len: usize,
2933
/// Stable item ID for the current user-message being assembled from SSE chunks.
3034
user_message_item_id: Option<String>,
3135
/// Buffered text for the current user-message being assembled from SSE chunks.
@@ -126,8 +130,10 @@ impl SessionTranslationState {
126130
turn_state.tool_call_items.clear();
127131
turn_state.agent_message_item_id = None;
128132
turn_state.agent_message_part_id = None;
133+
turn_state.agent_message_text_len = 0;
129134
turn_state.reasoning_item_id = None;
130135
turn_state.reasoning_part_id = None;
136+
turn_state.reasoning_text_len = 0;
131137
turn_state.user_message_item_id = preserved_user_message_item_id;
132138
turn_state.user_message_text = preserved_user_message_text;
133139
}
@@ -141,6 +147,9 @@ impl SessionTranslationState {
141147
turn_state.agent_message_item_id = None;
142148
turn_state.reasoning_item_id = None;
143149
turn_state.reasoning_part_id = None;
150+
turn_state.reasoning_text_len = 0;
151+
turn_state.agent_message_part_id = None;
152+
turn_state.agent_message_text_len = 0;
144153
// Preserve user_message_item_id so replay reuses the same ID
145154
// the live SSE translator already emitted (prevents duplicates).
146155
turn_state.user_message_text.clear();
@@ -185,6 +194,7 @@ impl SessionTranslationState {
185194
let turn_state = self.get_turn_state_mut(session_id);
186195
turn_state.agent_message_item_id = None;
187196
turn_state.agent_message_part_id = None;
197+
turn_state.agent_message_text_len = 0;
188198
}
189199

190200
fn remove_part_mapping(&mut self, session_id: &str, part_id: &str) {
@@ -196,10 +206,12 @@ impl SessionTranslationState {
196206
if turn_state.reasoning_part_id.as_deref() == Some(part_id) {
197207
turn_state.reasoning_part_id = None;
198208
turn_state.reasoning_item_id = None;
209+
turn_state.reasoning_text_len = 0;
199210
}
200211
if turn_state.agent_message_part_id.as_deref() == Some(part_id) {
201212
turn_state.agent_message_part_id = None;
202213
turn_state.agent_message_item_id = None;
214+
turn_state.agent_message_text_len = 0;
203215
}
204216
}
205217

@@ -209,8 +221,10 @@ impl SessionTranslationState {
209221
turn_state.tool_call_items.clear();
210222
turn_state.agent_message_item_id = None;
211223
turn_state.agent_message_part_id = None;
224+
turn_state.agent_message_text_len = 0;
212225
turn_state.reasoning_item_id = None;
213226
turn_state.reasoning_part_id = None;
227+
turn_state.reasoning_text_len = 0;
214228
turn_state.user_message_item_id = None;
215229
turn_state.user_message_text.clear();
216230
}
@@ -233,8 +247,10 @@ impl SessionTranslationState {
233247
turn_state.tool_call_items.clear();
234248
turn_state.agent_message_item_id = None;
235249
turn_state.agent_message_part_id = None;
250+
turn_state.agent_message_text_len = 0;
236251
turn_state.reasoning_item_id = None;
237252
turn_state.reasoning_part_id = None;
253+
turn_state.reasoning_text_len = 0;
238254
}
239255
}
240256

@@ -256,6 +272,19 @@ impl SessionTranslationState {
256272
}
257273
}
258274

275+
fn unseen_suffix<'a>(full_text: &'a str, emitted_len: usize) -> Option<&'a str> {
276+
if full_text.is_empty() {
277+
return None;
278+
}
279+
if emitted_len == 0 {
280+
return Some(full_text);
281+
}
282+
if full_text.len() <= emitted_len {
283+
return None;
284+
}
285+
full_text.get(emitted_len..).filter(|suffix| !suffix.is_empty())
286+
}
287+
259288
fn parse_u64(value: Option<&Value>) -> Option<u64> {
260289
value
261290
.and_then(|v| {
@@ -542,10 +571,20 @@ fn translate_part_updated(properties: &Value, state: &mut SessionTranslationStat
542571
// Emit a userMessage item so the prompt is visible immediately
543572
// (particularly important for subagent sessions whose prompts
544573
// aren't injected by send_user_message_core).
574+
let effective_text_owned;
545575
let effective_text = if delta.is_empty() {
546-
part.get("text")
547-
.and_then(|v| v.as_str())
548-
.unwrap_or_default()
576+
let full_text = part.get("text").and_then(|v| v.as_str()).unwrap_or_default();
577+
let current_user_text = state
578+
.get_turn_state(&thread_id)
579+
.map(|ts| ts.user_message_text.clone())
580+
.unwrap_or_default();
581+
if full_text.starts_with(current_user_text.as_str()) {
582+
let suffix = &full_text[current_user_text.len()..];
583+
effective_text_owned = suffix.to_string();
584+
effective_text_owned.as_str()
585+
} else {
586+
full_text
587+
}
549588
} else {
550589
delta
551590
};
@@ -575,18 +614,37 @@ fn translate_part_updated(properties: &Value, state: &mut SessionTranslationStat
575614
}
576615
if let Some(pid) = part.get("id").and_then(|v| v.as_str()) {
577616
let turn_state = state.get_turn_state_mut(&thread_id);
578-
turn_state.agent_message_part_id = Some(pid.to_string());
617+
if turn_state.agent_message_part_id.as_deref() != Some(pid) {
618+
turn_state.agent_message_part_id = Some(pid.to_string());
619+
turn_state.agent_message_text_len = 0;
620+
}
579621
}
580622
let effective_delta = if delta.is_empty() {
581-
part.get("text")
582-
.and_then(|v| v.as_str())
583-
.unwrap_or_default()
623+
let full_text = part.get("text").and_then(|v| v.as_str()).unwrap_or_default();
624+
let emitted_len = state
625+
.get_turn_state(&thread_id)
626+
.map(|ts| ts.agent_message_text_len)
627+
.unwrap_or(0);
628+
match unseen_suffix(full_text, emitted_len) {
629+
Some(suffix) => suffix,
630+
None => return vec![],
631+
}
584632
} else {
585633
delta
586634
};
587635
if effective_delta.is_empty() {
588636
return vec![];
589637
}
638+
if delta.is_empty() {
639+
let full_len = part
640+
.get("text")
641+
.and_then(|v| v.as_str())
642+
.map(|s| s.len())
643+
.unwrap_or(effective_delta.len());
644+
state.get_turn_state_mut(&thread_id).agent_message_text_len = full_len;
645+
} else {
646+
state.get_turn_state_mut(&thread_id).agent_message_text_len += effective_delta.len();
647+
}
590648

591649
let item_id = state.agent_message_item(&thread_id);
592650
vec![json!({
@@ -606,18 +664,37 @@ fn translate_part_updated(properties: &Value, state: &mut SessionTranslationStat
606664
}
607665
if let Some(pid) = part.get("id").and_then(|v| v.as_str()) {
608666
let turn_state = state.get_turn_state_mut(&thread_id);
609-
turn_state.reasoning_part_id = Some(pid.to_string());
667+
if turn_state.reasoning_part_id.as_deref() != Some(pid) {
668+
turn_state.reasoning_part_id = Some(pid.to_string());
669+
turn_state.reasoning_text_len = 0;
670+
}
610671
}
611672
let effective_delta = if delta.is_empty() {
612-
part.get("text")
613-
.and_then(|v| v.as_str())
614-
.unwrap_or_default()
673+
let full_text = part.get("text").and_then(|v| v.as_str()).unwrap_or_default();
674+
let emitted_len = state
675+
.get_turn_state(&thread_id)
676+
.map(|ts| ts.reasoning_text_len)
677+
.unwrap_or(0);
678+
match unseen_suffix(full_text, emitted_len) {
679+
Some(suffix) => suffix,
680+
None => return vec![],
681+
}
615682
} else {
616683
delta
617684
};
618685
if effective_delta.is_empty() {
619686
return vec![];
620687
}
688+
if delta.is_empty() {
689+
let full_len = part
690+
.get("text")
691+
.and_then(|v| v.as_str())
692+
.map(|s| s.len())
693+
.unwrap_or(effective_delta.len());
694+
state.get_turn_state_mut(&thread_id).reasoning_text_len = full_len;
695+
} else {
696+
state.get_turn_state_mut(&thread_id).reasoning_text_len += effective_delta.len();
697+
}
621698
let item_id = state.reasoning_item(&thread_id);
622699
vec![json!({
623700
"method": "item/reasoning/textDelta",
@@ -751,6 +828,7 @@ fn translate_part_delta(properties: &Value, state: &mut SessionTranslationState)
751828

752829
if is_reasoning {
753830
let item_id = state.reasoning_item(&thread_id);
831+
state.get_turn_state_mut(&thread_id).reasoning_text_len += delta.len();
754832
return vec![json!({
755833
"method": "item/reasoning/textDelta",
756834
"params": {
@@ -763,6 +841,7 @@ fn translate_part_delta(properties: &Value, state: &mut SessionTranslationState)
763841
}
764842

765843
let item_id = state.agent_message_item(&thread_id);
844+
state.get_turn_state_mut(&thread_id).agent_message_text_len += delta.len();
766845
vec![json!({
767846
"method": "item/agentMessage/delta",
768847
"params": {
@@ -2429,6 +2508,62 @@ mod tests {
24292508
assert_eq!(events[0]["params"]["delta"], "hello");
24302509
}
24312510

2511+
#[test]
2512+
fn text_part_updated_uses_only_unseen_suffix_after_streamed_deltas() {
2513+
let mut state = make_state();
2514+
2515+
let delta1 = json!({
2516+
"type": "message.part.delta",
2517+
"properties": {
2518+
"sessionID": "ses_test123",
2519+
"messageID": "msg_1",
2520+
"partID": "prt_text_1",
2521+
"field": "text",
2522+
"delta": "I'm currently in Plan Mode (read-only), "
2523+
}
2524+
});
2525+
let events1 = translate_sse_event(&delta1, &mut state);
2526+
assert_eq!(events1.len(), 1);
2527+
assert_eq!(events1[0]["params"]["delta"], "I'm currently in Plan Mode (read-only), ");
2528+
2529+
let delta2 = json!({
2530+
"type": "message.part.delta",
2531+
"properties": {
2532+
"sessionID": "ses_test123",
2533+
"messageID": "msg_1",
2534+
"partID": "prt_text_1",
2535+
"field": "text",
2536+
"delta": "so I can't make any file edits right now."
2537+
}
2538+
});
2539+
let events2 = translate_sse_event(&delta2, &mut state);
2540+
assert_eq!(events2.len(), 1);
2541+
assert_eq!(
2542+
events2[0]["params"]["delta"],
2543+
"so I can't make any file edits right now."
2544+
);
2545+
2546+
let updated = json!({
2547+
"type": "message.part.updated",
2548+
"properties": {
2549+
"part": {
2550+
"type": "text",
2551+
"id": "prt_text_1",
2552+
"sessionID": "ses_test123",
2553+
"messageID": "msg_1",
2554+
"text": "I'm currently in Plan Mode (read-only), so I can't make any file edits right now.\n\nTo make edits, switch out of Plan Mode first."
2555+
}
2556+
}
2557+
});
2558+
let events3 = translate_sse_event(&updated, &mut state);
2559+
assert_eq!(events3.len(), 1);
2560+
assert_eq!(events3[0]["method"], "item/agentMessage/delta");
2561+
assert_eq!(
2562+
events3[0]["params"]["delta"],
2563+
"\n\nTo make edits, switch out of Plan Mode first."
2564+
);
2565+
}
2566+
24322567
#[test]
24332568
fn user_text_part_updated_emits_user_message_item() {
24342569
let mut state = make_state();

src-tauri/src/shared/codex_core.rs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,18 @@ fn collaboration_mode_entry_from_agent(agent: &Value) -> Option<Value> {
259259
return None;
260260
}
261261

262+
let agent_mode = agent
263+
.get("mode")
264+
.and_then(|v| v.as_str())
265+
.map(str::trim)
266+
.filter(|value| !value.is_empty());
267+
if let Some(mode) = agent_mode {
268+
// Collaboration mode picker should only show primary-capable agents.
269+
if mode != "primary" && mode != "all" {
270+
return None;
271+
}
272+
}
273+
262274
let hidden = agent
263275
.get("hidden")
264276
.and_then(|v| v.as_bool())
@@ -2000,20 +2012,36 @@ mod tests {
20002012
}
20012013

20022014
#[test]
2003-
fn collaboration_mode_entry_from_agent_includes_subagent_and_skips_hidden() {
2015+
fn collaboration_mode_entry_from_agent_keeps_primary_capable_and_skips_subagent_hidden() {
20042016
let subagent = collaboration_mode_entry_from_agent(&json!({
20052017
"name": "explore",
20062018
"mode": "subagent",
20072019
"hidden": false,
20082020
"description": "Explore-only agent"
2021+
}));
2022+
assert!(subagent.is_none());
2023+
2024+
let primary = collaboration_mode_entry_from_agent(&json!({
2025+
"name": "build",
2026+
"mode": "primary",
2027+
"hidden": false,
2028+
"description": "Primary agent"
20092029
}))
2010-
.expect("subagent should be included");
2011-
assert_eq!(subagent["mode"], "explore");
2012-
assert_eq!(subagent["label"], "Explore");
2030+
.expect("primary agent should be included");
2031+
assert_eq!(primary["mode"], "build");
2032+
assert_eq!(primary["label"], "Build");
2033+
2034+
let dual_role = collaboration_mode_entry_from_agent(&json!({
2035+
"name": "general",
2036+
"mode": "all",
2037+
"hidden": false
2038+
}))
2039+
.expect("all-mode agent should be included");
2040+
assert_eq!(dual_role["mode"], "general");
20132041

20142042
let hidden = collaboration_mode_entry_from_agent(&json!({
20152043
"name": "summary",
2016-
"mode": "subagent",
2044+
"mode": "primary",
20172045
"hidden": true
20182046
}));
20192047
assert!(hidden.is_none());

src/features/threads/hooks/threadReducer/threadLifecycleSlice.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ import { prefersUpdatedSort } from "./common";
44

55
type ThreadStatus = ThreadState["threadStatusById"][string];
66

7+
function sortThreadsByUpdatedAtDesc(threads: ThreadSummary[]): ThreadSummary[] {
8+
const originalIndexById = new Map(
9+
threads.map((thread, index) => [thread.id, index] as const),
10+
);
11+
return [...threads].sort((a, b) => {
12+
const aUpdated = a.updatedAt ?? 0;
13+
const bUpdated = b.updatedAt ?? 0;
14+
if (bUpdated !== aUpdated) {
15+
return bUpdated - aUpdated;
16+
}
17+
const aIndex = originalIndexById.get(a.id) ?? Number.MAX_SAFE_INTEGER;
18+
const bIndex = originalIndexById.get(b.id) ?? Number.MAX_SAFE_INTEGER;
19+
if (aIndex !== bIndex) {
20+
return aIndex - bIndex;
21+
}
22+
return a.id.localeCompare(b.id);
23+
});
24+
}
25+
726
function statusEquals(previous: ThreadStatus, nextStatus: ThreadStatus) {
827
return (
928
previous.isProcessing === nextStatus.isProcessing &&
@@ -296,10 +315,7 @@ export function reduceThreadLifecycle(
296315
return state;
297316
}
298317
const sorted = prefersUpdatedSort(state, action.workspaceId)
299-
? [
300-
...next.filter((thread) => thread.id === action.threadId),
301-
...next.filter((thread) => thread.id !== action.threadId),
302-
]
318+
? sortThreadsByUpdatedAtDesc(next)
303319
: next;
304320
return {
305321
...state,

0 commit comments

Comments
 (0)