From ba535c281decec5e1e66f5807dac8840ac4177b1 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 30 Oct 2026 17:34:06 +0800 Subject: [PATCH 1/2] fix(composer): trim prompt suggestion whitespace --- src/crates/execution/agent-runtime/AGENTS.md | 4 +-- .../agent-runtime/src/prompt_suggestion.rs | 34 ++++++++++++++++--- src/web-ui/AGENTS.md | 9 +++++ .../EventHandlerModule.test.ts | 6 ++-- .../store/promptSuggestionStore.test.ts | 20 +++++++++++ .../flow_chat/store/promptSuggestionStore.ts | 17 +++++++--- 6 files changed, 75 insertions(+), 15 deletions(-) diff --git a/src/crates/execution/agent-runtime/AGENTS.md b/src/crates/execution/agent-runtime/AGENTS.md index 0ab919f9e7..5463852d2c 100644 --- a/src/crates/execution/agent-runtime/AGENTS.md +++ b/src/crates/execution/agent-runtime/AGENTS.md @@ -162,8 +162,8 @@ so `autotests = false` cannot silently omit a new contract. ## Verification -For next-input suggestion eligibility, plain-text output without text -post-processing, and cancellation: +For next-input suggestion eligibility, surrounding whitespace trimming with +message content preservation, and cancellation: ```bash cargo test --locked -p openbitfun-agent-runtime --no-default-features --features agent-runtime --lib prompt_suggestion::tests diff --git a/src/crates/execution/agent-runtime/src/prompt_suggestion.rs b/src/crates/execution/agent-runtime/src/prompt_suggestion.rs index 7821e8ea58..339bb225cb 100644 --- a/src/crates/execution/agent-runtime/src/prompt_suggestion.rs +++ b/src/crates/execution/agent-runtime/src/prompt_suggestion.rs @@ -36,12 +36,12 @@ pub fn prompt_suggestion_instruction() -> &'static str { PROMPT_SUGGESTION_INSTRUCTION } -/// Empty output and the literal null decline a suggestion. Preserve all other -/// model text verbatim; shape and length are guided only by the prompt. +/// Trim surrounding whitespace before publishing a suggestion. Empty output +/// and the literal null decline it; preserve the message content otherwise. pub fn prompt_suggestion_text(raw: String) -> Option { match raw.trim() { "" | "null" => None, - _ => Some(raw), + text => Some(text.to_owned()), } } @@ -233,9 +233,9 @@ mod tests { } #[test] - fn plain_text_suggestion_preserves_the_model_text_verbatim() { + fn plain_text_suggestion_preserves_message_content() { for text in [ - " Suggestion: \"Run the tests. Then commit.\" ".to_string(), + "Suggestion: \"Run the tests. Then commit.\"".to_string(), "我来运行测试。然后提交。\n不要裁剪这段文本。".to_string(), "a".repeat(300), "NONE".to_string(), @@ -246,6 +246,30 @@ mod tests { } } + #[test] + fn plain_text_suggestion_trims_only_surrounding_whitespace() { + for (raw, expected) in [ + ( + "\n\nSave these preferences in USER.md", + "Save these preferences in USER.md", + ), + (" \r\n\tRun the tests.\t \r\n", "Run the tests."), + ( + "\u{00a0}\u{3000}Run the tests.\u{3000}\u{00a0}", + "Run the tests.", + ), + ( + "\nRun the tests.\n\n Then commit.\n", + "Run the tests.\n\n Then commit.", + ), + ] { + assert_eq!( + prompt_suggestion_text(raw.to_string()), + Some(expected.to_string()) + ); + } + } + #[test] fn empty_output_and_literal_null_decline_a_suggestion() { for raw in ["", " \n\t ", "null", " \nnull\t "] { diff --git a/src/web-ui/AGENTS.md b/src/web-ui/AGENTS.md index b81d2e3f3d..19e61f7c1d 100644 --- a/src/web-ui/AGENTS.md +++ b/src/web-ui/AGENTS.md @@ -212,6 +212,15 @@ For text-entry guards, composer sanitization, or keyboard focus routing, run These cover DOM event ownership, controlled fields, IME and selection preservation; they do not establish native OS keyboard or remote transport behavior. +For prompt suggestion whitespace normalization, visibility, acceptance keys, and legacy host events: + +```bash +pnpm --dir src/web-ui run test:run src/flow_chat/store/promptSuggestionStore.test.ts src/flow_chat/hooks/usePromptSuggestionsEnabled.test.tsx src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +``` + +These local store/event fixtures include simulated Peer identities and legacy payloads; +they do not establish native WebView rendering or live remote behavior. + For ecosystem discovery, import, or compatibility status presentation changes: ```bash diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index 962cdeb9e1..42cb8e4027 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -147,15 +147,15 @@ describe('prompt suggestion projection', () => { usePromptSuggestionStore.setState({ suggestions: {} }); }); - it('preserves the model text for the latest turn without post-processing', () => { + it('trims surrounding whitespace in legacy suggestion events while preserving message content', () => { createSessionWithTurn(createFinishingTurn()); handlePromptSuggestion(createFlowChatContext(), { - sessionId: 'session-1', sourceTurnId: 'turn-1', suggestion: ' Run the tests ', + sessionId: 'session-1', sourceTurnId: 'turn-1', suggestion: '\n\n Run the tests\n Then commit. \r\n', }); expect(usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('session-1')]).toEqual({ - suggestion: ' Run the tests ', sourceTurnId: 'turn-1', + suggestion: 'Run the tests\n Then commit.', sourceTurnId: 'turn-1', }); }); diff --git a/src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts b/src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts index aa267a1abb..0a5f84a771 100644 --- a/src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts +++ b/src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts @@ -20,6 +20,26 @@ afterEach(() => { }); describe('prompt suggestion store', () => { + it.each([LOCAL_SURFACE_ID, 'legacy-peer'])('trims surrounding whitespace before displaying or accepting a suggestion from %s', surfaceId => { + const incoming = { + suggestion: '\r\n\n\t\u00a0\u3000Save these preferences in USER.md\n\n Keep the examples.\u3000\u00a0\t\r\n', + sourceTurnId: 't1', + }; + usePromptSuggestionStore.getState().setSuggestion('session', incoming, surfaceId); + + const stored = usePromptSuggestionStore.getState().suggestions[promptSuggestionKey('session', surfaceId)]; + expect(stored).toEqual({ + suggestion: 'Save these preferences in USER.md\n\n Keep the examples.', sourceTurnId: 't1', + }); + expect(visiblePromptSuggestion(stored, idle)).toBe('Save these preferences in USER.md\n\n Keep the examples.'); + expect(incoming.suggestion).toMatch(/^\r\n\n/); + }); + + it.each(['', ' \t\r\n', '\u00a0\u3000'])('ignores blank suggestions (%j)', suggestion => { + usePromptSuggestionStore.getState().setSuggestion('session', { suggestion, sourceTurnId: 't1' }); + expect(usePromptSuggestionStore.getState().suggestions).toEqual({}); + }); + it('clears disabled host hints, rejects late results, and keeps other hosts independent', () => { const store = usePromptSuggestionStore.getState(); store.setSuggestion('a', candidate, LOCAL_SURFACE_ID); diff --git a/src/web-ui/src/flow_chat/store/promptSuggestionStore.ts b/src/web-ui/src/flow_chat/store/promptSuggestionStore.ts index dfd5323330..ae4be64813 100644 --- a/src/web-ui/src/flow_chat/store/promptSuggestionStore.ts +++ b/src/web-ui/src/flow_chat/store/promptSuggestionStore.ts @@ -28,11 +28,18 @@ export const usePromptSuggestionStore = create((set) => ( suggestions: enabled ? state.suggestions : Object.fromEntries(Object.entries(state.suggestions) .filter(([key]) => JSON.parse(key)[0] !== surfaceId)), })), - setSuggestion: (sessionId, suggestion, surfaceId = getActiveSurfaceId()) => set((state) => ( - state.enabledBySurface[surfaceId] === false ? state : { - suggestions: { ...state.suggestions, [promptSuggestionKey(sessionId, surfaceId)]: suggestion }, - } - )), + setSuggestion: (sessionId, suggestion, surfaceId = getActiveSurfaceId()) => set((state) => { + if (state.enabledBySurface[surfaceId] === false) return state; + // Older hosts may send padded model output. Display and accept the same clean text. + const text = suggestion.suggestion.trim(); + if (!text) return state; + return { + suggestions: { + ...state.suggestions, + [promptSuggestionKey(sessionId, surfaceId)]: { ...suggestion, suggestion: text }, + }, + }; + }), clearSuggestion: (sessionId, surfaceId) => set((state) => { const key = promptSuggestionKey(sessionId, surfaceId); if (!(key in state.suggestions)) return state; From 579d5e52b0848008ce9289fc56a7ca6c503017b4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 30 Oct 2026 17:46:31 +0800 Subject: [PATCH 2/2] test(agent): verify trimmed prompt suggestion events --- src/crates/assembly/core/src/agentic/execution/AGENTS.md | 5 +++-- .../core/src/agentic/execution/prompt_suggestion.rs | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/execution/AGENTS.md b/src/crates/assembly/core/src/agentic/execution/AGENTS.md index 50a71f827a..1c0f7bf96d 100644 --- a/src/crates/assembly/core/src/agentic/execution/AGENTS.md +++ b/src/crates/assembly/core/src/agentic/execution/AGENTS.md @@ -34,8 +34,9 @@ a full cache miss. If the model call fails, retry it once unchanged. Both attempts share one 80-second deadline and session cancellation. Clear the turn's output schema, retain the request prefix and cache routing, and request plain text on both attempts. Empty output, the literal `null`, tool calls, and -content filters do not trigger the retry; all other candidate text is kept -verbatim. Do not add JSON parsing, format negotiation, correction retries, or +content filters do not trigger the retry. Trim surrounding whitespace before +publishing a candidate; preserve its internal whitespace and content verbatim. +Do not add JSON parsing, format negotiation, correction retries, or text reconstruction. The prepended deferred tool listing renders the session's skill/agent listing diff --git a/src/crates/assembly/core/src/agentic/execution/prompt_suggestion.rs b/src/crates/assembly/core/src/agentic/execution/prompt_suggestion.rs index 3cc91f7f14..66cdecb5b1 100644 --- a/src/crates/assembly/core/src/agentic/execution/prompt_suggestion.rs +++ b/src/crates/assembly/core/src/agentic/execution/prompt_suggestion.rs @@ -517,7 +517,7 @@ mod tests { let events = queue.dequeue_batch(10).await; assert_eq!(events.len(), 1); assert!( - matches!(&events[0].event, AgenticEvent::PromptSuggestionGenerated { suggestion, .. } if suggestion == text) + matches!(&events[0].event, AgenticEvent::PromptSuggestionGenerated { suggestion, .. } if suggestion == "Run the tests.") ); assert_eq!( session_client @@ -594,8 +594,8 @@ mod tests { } #[tokio::test] - async fn plain_text_response_emits_one_unchanged_candidate_without_output_schema() { - let text = " 运行测试,保留原文。 "; + async fn plain_text_response_emits_one_trimmed_candidate_without_output_schema() { + let text = " \r\n\tRun the tests.\n\n Keep this indentation.\t \r\n"; let server = RetryTestServer::new(vec![suggestion_response(text)]); let mut request = test_suggestion_request(server.client()); request.model_request_context.output_schema = Some(serde_json::json!({ @@ -616,7 +616,7 @@ mod tests { let events = queue.dequeue_batch(10).await; assert_eq!(events.len(), 1); assert!( - matches!(&events[0].event, AgenticEvent::PromptSuggestionGenerated { suggestion, .. } if suggestion == text) + matches!(&events[0].event, AgenticEvent::PromptSuggestionGenerated { suggestion, .. } if suggestion == "Run the tests.\n\n Keep this indentation.") ); let requests = server.requests.lock().unwrap(); assert_eq!(requests.len(), 1);