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
5 changes: 3 additions & 2 deletions src/crates/assembly/core/src/agentic/execution/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!({
Expand All @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/crates/execution/agent-runtime/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 29 additions & 5 deletions src/crates/execution/agent-runtime/src/prompt_suggestion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
match raw.trim() {
"" | "null" => None,
_ => Some(raw),
text => Some(text.to_owned()),
}
}

Expand Down Expand Up @@ -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(),
Expand All @@ -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 "] {
Expand Down
9 changes: 9 additions & 0 deletions src/web-ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
});
});

Expand Down
20 changes: 20 additions & 0 deletions src/web-ui/src/flow_chat/store/promptSuggestionStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 12 additions & 5 deletions src/web-ui/src/flow_chat/store/promptSuggestionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,18 @@ export const usePromptSuggestionStore = create<PromptSuggestionState>((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;
Expand Down
Loading