From 0c2e9b33c71621b8dbba336779620147c6bad612 Mon Sep 17 00:00:00 2001 From: xjx <371425010@qq.com> Date: Wed, 19 Aug 2026 14:04:21 +0800 Subject: [PATCH] fix(connections): keep DSH Agent/thinking body in the bubble during tool activity (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - DSH final `assistant/message` now projects immediately at the message boundary instead of waiting for `turn/end`: visible `text` blocks become bounded Agent body copy, explicit string `reasoning` blocks become bounded thinking body copy, and a message carrying both lets the visible text win the same-sequence selection. The edge is display-only (`affects_activity=false`), so it never restarts a thinking animation or pulls an in-progress tool state back to start/thinking. - A text-less final step clears the cached assistant text so a terminal edge cannot resurrect stale body copy over newer thinking. - `user/message` now reads the real rc.7 direct payload shape (`event.data` is the `UserMessage`); a bounded historical nested `{ message }` fallback is retained, and only `source.kind === 'user'` counts as user context. - `todo/write` projects exactly one closed, bounded plan line (first `in_progress`, else first `pending`, else last `completed`); the whole list is never serialized. - Streaming deltas remain dropped locally; tool args/results, usage, credentials, and full host objects never cross the contract. - DSH connector contract bumped to `dsh-v0.1.0-rc.7-events-v3` across plugin template, Rust constant, fixtures, and validations (host version is diagnostic context only, not a compat gate). - Current-state `docs/integrations/agent-connectors.md` updated; changelog fragment added under `changes/unreleased/`. ## Verification - `node --test plugins/connector-privacy.test.mjs` — real-shape rc.7 fixtures, immediate assistant/message delivery, reasoning/text precedence, stale-cache clearing, todo selection, non-user source suppression: pass - `cargo test -p petcore --lib dsh_adapter_tests` — 5 pass - `cargo test -p petcore --test integration_d --locked` — 164 pass, including the new real-chain regression `dsh_real_chain_keeps_latest_agent_and_thinking_body_across_tool_activity` (raw envelopes through `parse_dsh` → `agent.ingest` → bubble projection) - `./script/validate_connectors_runtime.sh`, `./script/validate_security_boundaries.sh` — pass - `./script/validate_pre_push.sh` — pass; `cargo clippy` clean - UI acceptance not claimed: no live macOS bubble observation was made in this task; structural/automated proof only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Development claim / 开发声明 - Domain: `agent-connections` - Claim: `dsh-connector` - Base commit: `836419bb616e7ad03b8545e005274683d8785335` - Release preparation: `no` - Focused validation: - `cargo test -p petcore --test integration_d --locked` - `./script/validate_connectors_runtime.sh` Co-authored-by: gd-ops Co-authored-by: Claude Opus 4.8 (1M context) --- .../20260819-dsh-bubble-message.json | 7 + crates/petcore/src/adapter_contracts.rs | 107 +++++++++++++- .../integration_d/agent_state_arbitration.rs | 131 ++++++++++++++++++ .../integration_d/connector_contracts.rs | 38 +++++ docs/integrations/agent-connectors.md | 4 +- .../assistant-message-reasoning.json | 1 + .../assistant-message-text.json | 1 + .../dsh-v0.1.0-rc.6/todo-write-plan.json | 1 + plugins/connector-privacy.test.mjs | 111 ++++++++++++++- plugins/dsh/agent-pet-companion.js.tpl | 86 ++++++++++-- script/validate_real_agent_connectors.sh | 4 +- 11 files changed, 472 insertions(+), 19 deletions(-) create mode 100644 changes/unreleased/20260819-dsh-bubble-message.json create mode 100644 fixtures/contracts/dsh-v0.1.0-rc.6/assistant-message-reasoning.json create mode 100644 fixtures/contracts/dsh-v0.1.0-rc.6/assistant-message-text.json create mode 100644 fixtures/contracts/dsh-v0.1.0-rc.6/todo-write-plan.json diff --git a/changes/unreleased/20260819-dsh-bubble-message.json b/changes/unreleased/20260819-dsh-bubble-message.json new file mode 100644 index 0000000..3963847 --- /dev/null +++ b/changes/unreleased/20260819-dsh-bubble-message.json @@ -0,0 +1,7 @@ +{ + "schema_version": "apc.changelog-fragment.v1", + "kind": "Fixed", + "scope": "dsh", + "summary_en": "DeepSeek Harness tasks now keep the latest Agent, thinking, or plan text in the desktop bubble while tools run, instead of showing an empty body until the task ends.", + "summary_zh": "DeepSeek Harness 任务在工具运行期间,桌面气泡会持续保留最新的 Agent、思考或计划正文,不再在任务结束前一直显示空正文。" +} diff --git a/crates/petcore/src/adapter_contracts.rs b/crates/petcore/src/adapter_contracts.rs index 6ffd3ca..a898dd8 100644 --- a/crates/petcore/src/adapter_contracts.rs +++ b/crates/petcore/src/adapter_contracts.rs @@ -16,7 +16,7 @@ pub const CODEX_HOOKS_CONTRACT_VERSION: &str = "codex-hooks-2026-08-01-events-v9 pub const CLAUDE_HOOKS_CONTRACT_VERSION: &str = "claude-hooks-2026-08-01-events-v9"; pub const PI_EXTENSION_CONTRACT_VERSION: &str = "pi-extension-0.80.10-events-v15"; pub const OPENCODE_CONTRACT_VERSION: &str = "opencode-v1.18.4-events-v16"; -pub const DSH_PLUGIN_CONTRACT_VERSION: &str = "dsh-v0.1.0-rc.6-events-v2"; +pub const DSH_PLUGIN_CONTRACT_VERSION: &str = "dsh-v0.1.0-rc.7-events-v3"; const MAX_MESSAGE_BYTES: usize = 4_096; const MAX_IDENTITY_BYTES: usize = 256; @@ -308,7 +308,15 @@ fn parse_dsh(source: AgentSource, input: &Value) -> Result true, ) } - "assistant/message" => return Ok(None), // cached for a later terminal edge + "assistant/message" => { + // An assembled final assistant message is a display-only edge that + // must persist its bounded visible text and/or explicit thinking + // content during execution, instead of waiting for a terminal edge. + // It never overrides the live tool/thinking activity (`affects_activity` + // stays false below). The Start kind here is refined to Thinking + // after extraction when only explicit reasoning is present. + (AgentEventType::Start, "message".to_string(), true) + } "plan/mode" => { if bool_at(input, &[&["active"], &["data", "active"]]) { (AgentEventType::Plan, "plan_mode_entered".to_string(), true) @@ -372,10 +380,40 @@ fn parse_dsh(source: AgentSource, input: &Value) -> Result let activity_kind = match event { "tool/call" | "tool/result" => Some(activity_kind_for_tool(tool_name.as_deref())), "todo/write" => Some("plan".to_string()), + // A final assistant message projects explicit reasoning as thinking body + // copy; the plugin only sets activity_kind when reasoning is present. + "assistant/message" => string_at( + input, + &[&["activity_kind"], &["properties", "activity_kind"]], + ) + .filter(|kind| kind == "thinking"), _ => None, }; - let affects_activity = !matches!(event, "connector.probe" | "session/title" | "session.child"); + // An empty assistant/message envelope (no visible text and no explicit + // thinking) carries no display content and must fail open, never + // synthesizing a spurious start state. + if event == "assistant/message" && message_content.is_none() && activity_kind.is_none() { + return Ok(None); + } + + // A final assistant message is display-only: it refreshes the bubble body + // (message/activity content) but must not restart a thinking animation or + // pull an in-progress tool state back to start/thinking. + let affects_activity = !matches!( + event, + "connector.probe" | "session/title" | "session.child" | "assistant/message" + ); + + // Refine a reasoning-only final message to the Thinking kind so it projects + // as explicit thinking body copy; a message carrying visible text keeps the + // Start carrier and lets the visible text win the same-sequence selection. + let (kind, outcome) = if event == "assistant/message" && activity_kind.is_some() { + (AgentEventType::Thinking, "reasoning".to_string()) + } else { + (kind, outcome) + }; + let mut contract = ContractEvent { source, external_event_id: None, @@ -2975,7 +3013,8 @@ mod dsh_adapter_tests { })) .is_none()); - // assistant/message -> Ok(None) (display-only, does not emit a pet state) + // assistant/message with no display content -> Ok(None) (empty envelope + // must fail open, never synthesize a start state). assert!(parse(&json!({ "type": "assistant/message", "session_id": "s1" @@ -2986,6 +3025,66 @@ mod dsh_adapter_tests { assert!(parse(&json!({"type": "turn/start"})).is_none()); } + #[test] + fn assistant_message_projects_display_content_without_overriding_activity() { + let parse = |input: &Value| { + parse_contract_event(AgentSource::Dsh, input) + .expect("parsed") + .expect("contract") + }; + + // Visible text only -> Start/message carrier persisting the Agent body. + let text_only = parse(&json!({ + "type": "assistant/message", + "session_id": "s1", + "turn_id": "2", + "message_role": "assistant", + "message_content": "我先检查工作树。" + })); + assert_eq!(text_only.kind, AgentEventType::Start); + assert_eq!(text_only.outcome.as_deref(), Some("message")); + assert_eq!(text_only.message_role.as_deref(), Some("assistant")); + assert_eq!( + text_only.message_content.as_deref(), + Some("我先检查工作树。") + ); + assert_eq!(text_only.activity_kind, None); + assert!(!text_only.affects_activity); + assert!(text_only.session_active); + + // Reasoning only -> Thinking/reasoning carrying explicit thinking body. + let reasoning_only = parse(&json!({ + "type": "assistant/message", + "session_id": "s1", + "activity_kind": "thinking", + "activity_content": "工作树干净,接下来核对最近提交。" + })); + assert_eq!(reasoning_only.kind, AgentEventType::Thinking); + assert_eq!(reasoning_only.outcome.as_deref(), Some("reasoning")); + assert_eq!(reasoning_only.activity_kind.as_deref(), Some("thinking")); + assert_eq!( + reasoning_only.activity_content.as_deref(), + Some("工作树干净,接下来核对最近提交。") + ); + assert_eq!(reasoning_only.message_content, None); + assert!(!reasoning_only.affects_activity); + + // Both text and reasoning in one final message -> the visible text wins + // the body while the thinking content stays available on the same row. + let both = parse(&json!({ + "type": "assistant/message", + "session_id": "s1", + "message_role": "assistant", + "message_content": "可见正文。", + "activity_kind": "thinking", + "activity_content": "显式思考。" + })); + assert_eq!(both.message_content.as_deref(), Some("可见正文。")); + assert_eq!(both.activity_kind.as_deref(), Some("thinking")); + assert_eq!(both.activity_content.as_deref(), Some("显式思考。")); + assert!(!both.affects_activity); + } + #[test] fn privacy_fixtures_forbid_credential_and_detail_leakage() { // tool/call with arguments -> arguments must NOT leak into outcome or activity_content diff --git a/crates/petcore/tests/integration_d/agent_state_arbitration.rs b/crates/petcore/tests/integration_d/agent_state_arbitration.rs index 84b5154..76cd54f 100644 --- a/crates/petcore/tests/integration_d/agent_state_arbitration.rs +++ b/crates/petcore/tests/integration_d/agent_state_arbitration.rs @@ -2458,6 +2458,137 @@ fn every_agent_keeps_only_the_latest_agent_or_thinking_message_across_tool_activ } } +// Real-chain regression: raw DSH connector envelopes are parsed by the actual +// `parse_dsh` contract (not pre-normalized fixtures) and ingested through the +// same agent.ingest projection the bubble reads. This proves the DSH connector +// itself produces the events that keep Agent/thinking body copy alive under +// tool activity, instead of only asserting PetCore's generic arbitration. +#[test] +fn dsh_real_chain_keeps_latest_agent_and_thinking_body_across_tool_activity() { + use petcore::adapter_contracts::parse_contract_event; + + let (_temp, state) = ready(); + let session_id = "dsh-real-chain-bubble"; + let projected = projected_session_id(session_id); + let session = || { + snapshot(&state)["active_agent_sessions"] + .as_array() + .unwrap() + .iter() + .find(|candidate| candidate["session_id"] == projected) + .unwrap() + .clone() + }; + // Parse a raw DSH envelope through the real contract, then ingest the + // normalized fields exactly as the CLI hook path would. + let ingest_raw = |id: &str, seconds_ago: i64, raw: Value| { + let contract = parse_contract_event(AgentSource::Dsh, &raw) + .expect("dsh parse") + .expect("dsh contract"); + let event_type = match contract.kind { + AgentEventType::Start => "start", + AgentEventType::Thinking => "thinking", + AgentEventType::Plan => "plan", + AgentEventType::Tool => "tool", + AgentEventType::Waiting => "waiting", + AgentEventType::Done => "done", + AgentEventType::Failed => "failed", + }; + ingest_source_payload( + &state, + "dsh", + id, + session_id, + event_type, + ×tamp(seconds_ago), + json!({ + "source_event": contract.source_event, + "outcome": contract.outcome, + "affects_activity": contract.affects_activity, + "session_active": contract.session_active, + "session_open": contract.session_open, + "message_role": contract.message_role, + "message_content": contract.message_content, + "activity_kind": contract.activity_kind, + "activity_content": contract.activity_content, + "session_title": contract.session_title, + }), + ); + }; + + // turn/start then a final assistant/message carrying visible text. + ingest_raw( + "rc-start", + 50, + json!({"type": "turn/start", "session_id": session_id, "data": {"turn": 1}}), + ); + ingest_raw( + "rc-agent-text", + 40, + json!({ + "type": "assistant/message", + "session_id": session_id, + "turn_id": "1", + "message_role": "assistant", + "message_content": "我先检查工作树。" + }), + ); + let after_text = session(); + assert_eq!( + after_text["session_message"], + json!({"role": "assistant", "content": "我先检查工作树。"}), + "the final assistant/message must project its visible text immediately" + ); + + // A tool call must flip the state to tool while preserving the Agent body. + ingest_raw( + "rc-tool", + 30, + json!({"type": "tool/call", "session_id": session_id, "tool_name": "bash", "data": {"turn": 1, "step": 1, "callId": "c1", "name": "bash", "arguments": "{}"}}), + ); + let after_tool = session(); + assert_eq!(after_tool["event"]["event_type"], "tool"); + assert_eq!( + after_tool["session_message"], + json!({"role": "assistant", "content": "我先检查工作树。"}), + "tool activity must not clear the retained Agent text" + ); + + // A reasoning-only final message replaces the body with explicit thinking. + ingest_raw( + "rc-reasoning", + 20, + json!({ + "type": "assistant/message", + "session_id": session_id, + "turn_id": "1", + "activity_kind": "thinking", + "activity_content": "工作树干净,接下来核对最近提交。" + }), + ); + let after_reasoning = session(); + assert_eq!(after_reasoning["session_message"], Value::Null); + assert_eq!( + after_reasoning["session_activity"], + json!({"kind": "thinking", "content": "工作树干净,接下来核对最近提交。"}), + "reasoning-only final message must project explicit thinking body" + ); + + // A later tool call keeps the thinking body. + ingest_raw( + "rc-tool-2", + 10, + json!({"type": "tool/call", "session_id": session_id, "tool_name": "read", "data": {"turn": 1, "step": 2, "callId": "c2", "name": "read", "arguments": "{}"}}), + ); + let after_tool_2 = session(); + assert_eq!(after_tool_2["event"]["event_type"], "tool"); + assert_eq!( + after_tool_2["session_activity"], + json!({"kind": "thinking", "content": "工作树干净,接下来核对最近提交。"}), + "tool activity must not clear the retained thinking body" + ); +} + #[test] fn equal_timestamp_messages_follow_persisted_arrival_order() { let (_temp, state) = ready(); diff --git a/crates/petcore/tests/integration_d/connector_contracts.rs b/crates/petcore/tests/integration_d/connector_contracts.rs index 9a72420..cdadc48 100644 --- a/crates/petcore/tests/integration_d/connector_contracts.rs +++ b/crates/petcore/tests/integration_d/connector_contracts.rs @@ -1475,4 +1475,42 @@ fn dsh_contract_fixtures_parse_authoritatively_with_bounds_and_privacy() { ) .unwrap() .is_none()); + + // 8. Final assistant/message with visible text -> Start/message display body + // during execution, without overriding the live activity. + let assistant_text = parsed( + AgentSource::Dsh, + "dsh-v0.1.0-rc.6/assistant-message-text.json", + ); + assert_eq!(assistant_text.kind, AgentEventType::Start); + assert_eq!(assistant_text.outcome.as_deref(), Some("message")); + assert_eq!(assistant_text.message_role.as_deref(), Some("assistant")); + assert_eq!( + assistant_text.message_content.as_deref(), + Some("我来探索这个项目。让我先了解一下项目的基本结构和关键文件。") + ); + assert!(!assistant_text.affects_activity); + + // 9. Final assistant/message with only explicit reasoning -> Thinking body. + let assistant_reasoning = parsed( + AgentSource::Dsh, + "dsh-v0.1.0-rc.6/assistant-message-reasoning.json", + ); + assert_eq!(assistant_reasoning.kind, AgentEventType::Thinking); + assert_eq!(assistant_reasoning.outcome.as_deref(), Some("reasoning")); + assert_eq!( + assistant_reasoning.activity_kind.as_deref(), + Some("thinking") + ); + assert_eq!( + assistant_reasoning.activity_content.as_deref(), + Some("先列出目录,再读 README 与入口文件。") + ); + assert!(!assistant_reasoning.affects_activity); + + // 10. todo/write -> Plan with one bounded selected plan line. + let todo = parsed(AgentSource::Dsh, "dsh-v0.1.0-rc.6/todo-write-plan.json"); + assert_eq!(todo.kind, AgentEventType::Plan); + assert_eq!(todo.activity_kind.as_deref(), Some("plan")); + assert_eq!(todo.activity_content.as_deref(), Some("梳理项目结构")); } diff --git a/docs/integrations/agent-connectors.md b/docs/integrations/agent-connectors.md index be8dcab..eec1cad 100644 --- a/docs/integrations/agent-connectors.md +++ b/docs/integrations/agent-connectors.md @@ -64,7 +64,7 @@ Activity normalization selects only bounded semantic scalars such as reasoning, | Claude Code | Tool descriptions, commands/results/errors, permission/input requests, sub-Agent and compaction hooks; no private model reasoning | | Pi | Tool input/output, bounded Agent reply, and explicit finalized `ThinkingContent.thinking`; no inference from token/timestamp churn | | OpenCode | Stable reasoning/plan boundaries, command/tool data, compaction, errors, and session steps | -| DeepSeek Harness | Reasoning `block-start` boundaries, clean tool names, plan/todo snapshots, approval requests/decisions, and subagent lifecycle | +| DeepSeek Harness | Reasoning `block-start` boundaries, final-message visible text and explicit reasoning, clean tool names, single-line plan/todo projections, approval requests/decisions, and subagent lifecycle | ## Event mapping @@ -88,7 +88,7 @@ Claude Code's delayed `idle_prompt` notification completes an ordinary turn only Pi emits `input` before it validates the interactive session's selected model. When the public Extension context has no model at that boundary, the managed adapter closes that admitted input directly as `failed` with the closed `model_unavailable` outcome; it never inspects provider credentials or auth storage. Other Pi failures continue to settle only on a stable terminal host boundary. -DeepSeek Harness emits stream chunks and lifecycle broadcasts through Cordis. The managed plugin subscribes only to the four session-scoped broadcast `emit` events (`session/event`, `subagent/start`, `subagent/end`, and `session/disposed`); `agent/status` is audited but intentionally not subscribed because it has no session identity. It explicitly avoids waterfall/interception events (`agent/pre-step`, `tools/pre-execute`, etc.) to prevent pure observers from stalling the driver chain. `assistant/chunk` events are filtered so only explicit `block-start{blockType:'reasoning'}` boundaries emit `thinking`, while all delta chunks and usage data are dropped locally. Stable host sequence numbers become opaque event identities so repeated same-tool events do not collapse. Bounded user/assistant text and title caches are attached only to semantic start/terminal edges. Active child subagents tracked via `subagent/start`/`subagent/end` maintain a per-parent background fence: a parent `turn/end{completed}` while subagents are running emits `background_active`, and the terminal `done/completed` is deferred until all child runs in that parent's fence settle. Root `session/disposed` is an explicit close and immediately removes any waiting, completed, or failed bubble for that session. +DeepSeek Harness emits stream chunks and lifecycle broadcasts through Cordis. The managed plugin subscribes only to the four session-scoped broadcast `emit` events (`session/event`, `subagent/start`, `subagent/end`, and `session/disposed`); `agent/status` is audited but intentionally not subscribed because it has no session identity. It explicitly avoids waterfall/interception events (`agent/pre-step`, `tools/pre-execute`, etc.) to prevent pure observers from stalling the driver chain. `assistant/chunk` events are filtered so only explicit `block-start{blockType:'reasoning'}` boundaries emit `thinking`, while all delta chunks and usage data are dropped locally. At each final `assistant/message` boundary the plugin projects the assembled message at once: visible `text` blocks become bounded Agent body copy and explicit, non-redacted string `reasoning` blocks become bounded thinking body copy. This is a display-only edge that refreshes the bubble body during execution without restarting a thinking animation or pulling an in-progress tool state back to start/thinking; when one final message carries both, the visible text wins the same-sequence body selection. A text-less final step clears the cached assistant text so a later terminal edge cannot resurrect stale body over newer thinking. User messages arrive as a direct `user/message` payload (`event.data` is the `UserMessage`); only a real `source.kind === 'user'` counts as user context, with a bounded historical nested `{ message }` fallback. `todo/write` projects exactly one closed, user-readable plan line (first in-progress, else first pending, else last completed) and never serializes the whole list. Stable host sequence numbers become opaque event identities so repeated same-tool events do not collapse. Bounded user/assistant text and title caches are attached only to semantic start/terminal edges as a delivery fallback. Active child subagents tracked via `subagent/start`/`subagent/end` maintain a per-parent background fence: a parent `turn/end{completed}` while subagents are running emits `background_active`, and the terminal `done/completed` is deferred until all child runs in that parent's fence settle. Root `session/disposed` is an explicit close and immediately removes any waiting, completed, or failed bubble for that session. ## Managed connection operations diff --git a/fixtures/contracts/dsh-v0.1.0-rc.6/assistant-message-reasoning.json b/fixtures/contracts/dsh-v0.1.0-rc.6/assistant-message-reasoning.json new file mode 100644 index 0000000..859c7ce --- /dev/null +++ b/fixtures/contracts/dsh-v0.1.0-rc.6/assistant-message-reasoning.json @@ -0,0 +1 @@ +{"type":"assistant/message","session_id":"session-abc","turn_id":"t1","activity_kind":"thinking","activity_content":"先列出目录,再读 README 与入口文件。","contract_version":"dsh-v0.1.0-rc.7-events-v3"} diff --git a/fixtures/contracts/dsh-v0.1.0-rc.6/assistant-message-text.json b/fixtures/contracts/dsh-v0.1.0-rc.6/assistant-message-text.json new file mode 100644 index 0000000..e1109a2 --- /dev/null +++ b/fixtures/contracts/dsh-v0.1.0-rc.6/assistant-message-text.json @@ -0,0 +1 @@ +{"type":"assistant/message","session_id":"session-abc","turn_id":"t1","message_role":"assistant","message_content":"我来探索这个项目。让我先了解一下项目的基本结构和关键文件。","contract_version":"dsh-v0.1.0-rc.7-events-v3"} diff --git a/fixtures/contracts/dsh-v0.1.0-rc.6/todo-write-plan.json b/fixtures/contracts/dsh-v0.1.0-rc.6/todo-write-plan.json new file mode 100644 index 0000000..86b4105 --- /dev/null +++ b/fixtures/contracts/dsh-v0.1.0-rc.6/todo-write-plan.json @@ -0,0 +1 @@ +{"type":"todo/write","session_id":"session-abc","activity_kind":"plan","activity_content":"梳理项目结构","contract_version":"dsh-v0.1.0-rc.7-events-v3"} diff --git a/plugins/connector-privacy.test.mjs b/plugins/connector-privacy.test.mjs index 1605eeb..0fa5208 100644 --- a/plugins/connector-privacy.test.mjs +++ b/plugins/connector-privacy.test.mjs @@ -1371,7 +1371,7 @@ test("Pi, OpenCode, and dsh expose bounded local activity without leaking host-p // --- DeepSeek Harness (dsh) template privacy and filtering tests --- delete process.env.APC_CONNECTOR_PROBE_ID; const dsh = await importTemplate("./dsh/agent-pet-companion.js.tpl", "dsh-privacy"); - assert.equal(dsh.APC_DSH_CONTRACT_VERSION, "dsh-v0.1.0-rc.6-events-v2"); + assert.equal(dsh.APC_DSH_CONTRACT_VERSION, "dsh-v0.1.0-rc.7-events-v3"); assert.equal(dsh.APC_DSH_AUDITED_EVENTS.length, 5); const dshListeners = new Map(); @@ -1457,6 +1457,7 @@ test("Pi, OpenCode, and dsh expose bounded local activity without leaking host-p assert.equal(captured.length, countBeforeDelta, "text-delta chunks must be dropped"); // Test 4: bounded display context travels on semantic edges, not private envelopes. + // Real rc.7 shape: `user/message` data IS the UserMessage directly. dshListeners.get("session/event")(parentSession, { type: "session/title", seq: 20, @@ -1465,7 +1466,7 @@ test("Pi, OpenCode, and dsh expose bounded local activity without leaking host-p dshListeners.get("session/event")(parentSession, { type: "user/message", seq: 21, - data: { message: { source: { kind: "user" }, content: [{ type: "text", text: "Review DSH" }] } }, + data: { source: { kind: "user" }, content: [{ type: "text", text: "Review DSH" }] }, }); dshListeners.get("session/event")(parentSession, { type: "turn/start", @@ -1475,7 +1476,7 @@ test("Pi, OpenCode, and dsh expose bounded local activity without leaking host-p dshListeners.get("session/event")(parentSession, { type: "assistant/message", seq: 23, - data: { message: { content: [{ type: "text", text: "DSH is ready" }] } }, + data: { turn: 2, step: 1, message: { content: [{ type: "text", text: "DSH is ready" }] } }, }); dshListeners.get("session/event")(parentSession, { type: "turn/end", @@ -1489,6 +1490,14 @@ test("Pi, OpenCode, and dsh expose bounded local activity without leaking host-p assert.equal(startPayload?.message_role, "user"); assert.equal(startPayload?.message_content, "Review DSH"); assert.equal(startPayload?.session_title, "DSH acceptance"); + // The final assistant/message now delivers its visible text immediately at + // the message boundary, not only via a later terminal edge. + const assistantPayload = captured.find((item) => ( + item.source === "dsh" && item.payload.type === "assistant/message" && item.payload.turn_id === "2" + ))?.payload; + assert.equal(assistantPayload?.message_role, "assistant"); + assert.equal(assistantPayload?.message_content, "DSH is ready"); + assert.equal(assistantPayload?.session_title, "DSH acceptance"); const donePayload = captured.find((item) => ( item.source === "dsh" && item.payload.type === "turn/end" && item.payload.turn_id === "2" ))?.payload; @@ -1496,6 +1505,102 @@ test("Pi, OpenCode, and dsh expose bounded local activity without leaking host-p assert.equal(donePayload?.message_content, "DSH is ready"); assert.equal(donePayload?.session_title, "DSH acceptance"); + // Test 4b: reasoning-only final message projects explicit thinking content. + const reasoningBefore = captured.length; + dshListeners.get("session/event")(parentSession, { + type: "assistant/message", + seq: 27, + data: { turn: 3, step: 1, message: { content: [{ type: "reasoning", text: "internal plan" }] } }, + }); + await new Promise((r) => setTimeout(r, 30)); + const reasoningPayload = captured.slice(reasoningBefore).find((item) => ( + item.source === "dsh" && item.payload.type === "assistant/message" && item.payload.turn_id === "3" + ))?.payload; + assert.equal(reasoningPayload?.activity_kind, "thinking"); + assert.equal(reasoningPayload?.activity_content, "internal plan"); + assert.equal(reasoningPayload?.message_content, undefined); + + // Test 4c: a final message with both text and reasoning projects text as the + // Agent body and keeps reasoning as the thinking content on the same event. + dshListeners.get("session/event")(parentSession, { + type: "assistant/message", + seq: 28, + data: { turn: 4, step: 1, message: { content: [ + { type: "reasoning", text: "thinking text" }, + { type: "text", text: "visible text" }, + ] } }, + }); + await new Promise((r) => setTimeout(r, 30)); + const bothPayload = captured.find((item) => ( + item.source === "dsh" && item.payload.type === "assistant/message" && item.payload.turn_id === "4" + ))?.payload; + assert.equal(bothPayload?.message_content, "visible text"); + assert.equal(bothPayload?.activity_content, "thinking text"); + + // Test 4d: a text-less final assistant/message emits nothing and clears the + // cached assistant text so a terminal edge cannot resurrect stale body copy. + const emptyBefore = captured.length; + dshListeners.get("session/event")(parentSession, { + type: "assistant/message", + seq: 29, + data: { turn: 5, step: 1, message: { content: [{ type: "reasoning", text: "" }] } }, + }); + await new Promise((r) => setTimeout(r, 30)); + assert.equal( + captured.slice(emptyBefore).filter((item) => item.payload?.type === "assistant/message").length, + 0, + "an assistant/message with no text or reasoning must not be delivered", + ); + + // Test 4e: historical nested user/message shape still projects; a non-user + // source kind is ignored as user context. + dshListeners.get("session/event")(parentSession, { + type: "user/message", + seq: 30, + data: { message: { source: { kind: "user" }, content: [{ type: "text", text: "legacy nested" }] } }, + }); + dshListeners.get("session/event")(parentSession, { + type: "user/message", + seq: 31, + data: { source: { kind: "plugin", plugin: "skill" }, content: [{ type: "text", text: "injected" }] }, + }); + await new Promise((r) => setTimeout(r, 30)); + assert.ok(captured.some((item) => ( + item.source === "dsh" + && item.payload.type === "user/message" + && item.payload.message_content === "legacy nested" + )), "historical nested user/message shape must remain bounded-compatible"); + assert.ok(!captured.some((item) => ( + item.payload?.type === "user/message" && item.payload?.message_content === "injected" + )), "non-user source kinds must never project as user context"); + + // Test 4f: todo/write projects one bounded plan line and never the array. + dshListeners.get("session/event")(parentSession, { + type: "todo/write", + seq: 32, + data: { todos: [ + { content: "done step", status: "completed" }, + { content: "current step", status: "in_progress" }, + { content: "later step", status: "pending" }, + ] }, + }); + await new Promise((r) => setTimeout(r, 30)); + const todoPayload = captured.find((item) => ( + item.source === "dsh" && item.payload.type === "todo/write" + ))?.payload; + assert.equal(todoPayload?.activity_kind, "plan"); + assert.equal(todoPayload?.activity_content, "current step"); + assert.equal(typeof todoPayload?.activity_content, "string"); + // Empty / malformed todo shapes project no plan body. + const todoEmptyBefore = captured.length; + dshListeners.get("session/event")(parentSession, { type: "todo/write", seq: 33, data: { todos: [] } }); + dshListeners.get("session/event")(parentSession, { type: "todo/write", seq: 34, data: { todos: [{ content: 42, status: "in_progress" }] } }); + dshListeners.get("session/event")(parentSession, { type: "todo/write", seq: 35, data: { todos: "not-an-array" } }); + await new Promise((r) => setTimeout(r, 30)); + assert.ok(captured.slice(todoEmptyBefore).every((item) => ( + item.payload?.type !== "todo/write" || item.payload?.activity_content === undefined + )), "empty, non-string-content, and non-array todos must project no plan body"); + // Test 5: durable host sequence differentiates otherwise identical tool events. dshListeners.get("session/event")(parentSession, { type: "tool/call", seq: 25, data: { turn: 3, name: "read" }, diff --git a/plugins/dsh/agent-pet-companion.js.tpl b/plugins/dsh/agent-pet-companion.js.tpl index 4014414..31da417 100644 --- a/plugins/dsh/agent-pet-companion.js.tpl +++ b/plugins/dsh/agent-pet-companion.js.tpl @@ -3,7 +3,7 @@ import { createHash, randomUUID } from "node:crypto"; const CLI_PATH = __APC_CLI_JSON__; export const APC_DSH_CONNECTOR_RELEASE_VERSION = "__APC_CONNECTOR_RELEASE_VERSION__"; -export const APC_DSH_CONTRACT_VERSION = "dsh-v0.1.0-rc.6-events-v2"; +export const APC_DSH_CONTRACT_VERSION = "dsh-v0.1.0-rc.7-events-v3"; // Audited dsh broadcast events. Agent Pet Companion subscribes ONLY to emit/ // broadcast events. Waterfall/interception events (agent/pre-step, @@ -195,6 +195,48 @@ function extractAssistantText(message) { return cleanText(parts.join("")); } +// Explicit thinking content is only projected from an assembled final message: +// a complete, non-redacted string `reasoning` block. Streaming reasoning-delta +// churn is dropped locally and never reaches this extractor. +function extractAssistantReasoning(message) { + if (!message || !Array.isArray(message.content)) return undefined; + const parts = []; + for (const block of message.content) { + if (block?.type === "reasoning" && typeof block.text === "string") { + parts.push(block.text); + } + } + return cleanText(parts.join("")); +} + +// `todo/write` carries a whole-list snapshot. Project exactly one bounded, +// user-readable plan line: the first in-progress item, else the first pending, +// else the last completed. Never serialize the array or unknown fields. +function selectTodoPlanContent(todos) { + if (!Array.isArray(todos)) return undefined; + const contentOf = (item) => (typeof item?.content === "string" ? item.content : undefined); + const first = (status) => { + for (const item of todos) { + if (item?.status === status) { + const content = cleanText(contentOf(item)); + if (content) return content; + } + } + return undefined; + }; + const inProgress = first("in_progress"); + if (inProgress) return inProgress; + const pending = first("pending"); + if (pending) return pending; + for (let index = todos.length - 1; index >= 0; index -= 1) { + if (todos[index]?.status === "completed") { + const content = cleanText(contentOf(todos[index])); + if (content) return content; + } + } + return undefined; +} + export function apply(ctx) { if (connectorProbe || connectorDiagnostic) { enqueueDelivery({ @@ -264,19 +306,44 @@ export function apply(ctx) { break; } case "assistant/message": { + // The assembled final message is the display boundary. Emit bounded + // visible text (and explicit reasoning) now instead of caching it for + // a terminal edge, so the bubble keeps body copy during long tool + // phases. Streaming text/reasoning deltas stay dropped locally. const text = extractAssistantText(data.message); - if (text) { + const reasoning = extractAssistantReasoning(data.message); + if (!text) { + // A text-less step must not let the terminal fallback resurrect the + // previous step's older Agent text over newer thinking content. + latestAssistantMessages.delete(sid); + } else { remember(latestAssistantMessages, sid, boundString(text, BOUND_MESSAGE_BYTES)); } + if (!text && !reasoning) break; + enqueueDelivery({ + type: "assistant/message", + session_id: sid, + eventID, + turn_id: typeof data.turn === "number" ? String(data.turn) : undefined, + session_title: sessionTitles.get(sid), + message_role: text ? "assistant" : undefined, + message_content: text ? boundString(text, BOUND_MESSAGE_BYTES) : undefined, + activity_kind: reasoning ? "thinking" : undefined, + activity_content: reasoning ? boundString(reasoning, BOUND_MESSAGE_BYTES) : undefined, + }); break; } case "user/message": { - const sourceKind = data.message?.source?.kind; - if (sourceKind === "user") { - const text = extractAssistantText(data.message); + // rc.7 contract: `event.data` IS the UserMessage directly. Keep a + // bounded historical `{ message: {...} }` nested fallback. Only a + // real human `source.kind === "user"` counts as user context; system, + // skill, plugin, and injected messages are never projected. + const message = data.message ?? data; + if (message?.source?.kind === "user") { + const text = extractAssistantText(message); if (text) { - const message = boundString(text, BOUND_MESSAGE_BYTES); - remember(latestUserMessages, sid, message); + const bounded = boundString(text, BOUND_MESSAGE_BYTES); + remember(latestUserMessages, sid, bounded); latestAssistantMessages.delete(sid); enqueueDelivery({ type: "user/message", @@ -284,7 +351,7 @@ export function apply(ctx) { eventID, session_title: sessionTitles.get(sid), message_role: "user", - message_content: message, + message_content: bounded, }); } } @@ -315,10 +382,13 @@ export function apply(ctx) { break; } case "todo/write": { + const plan = selectTodoPlanContent(data.todos); enqueueDelivery({ type: "todo/write", session_id: sid, eventID, + activity_kind: plan ? "plan" : undefined, + activity_content: plan ? boundString(plan, BOUND_MESSAGE_BYTES) : undefined, }); break; } diff --git a/script/validate_real_agent_connectors.sh b/script/validate_real_agent_connectors.sh index a529f52..ae7bded 100755 --- a/script/validate_real_agent_connectors.sh +++ b/script/validate_real_agent_connectors.sh @@ -234,7 +234,7 @@ expected_contracts = { "claude_code": "claude-hooks-2026-08-01-events-v9", "pi": "pi-extension-0.80.10-events-v15", "opencode": "opencode-v1.18.4-events-v16", - "dsh": "dsh-v0.1.0-rc.6-events-v2", + "dsh": "dsh-v0.1.0-rc.7-events-v3", } expected_capability_counts = { "codex": (80, 11), @@ -307,7 +307,7 @@ contracts = { "claude_code": "claude-hooks-2026-08-01-events-v9", "pi": "pi-extension-0.80.10-events-v15", "opencode": "opencode-v1.18.4-events-v16", - "dsh": "dsh-v0.1.0-rc.6-events-v2", + "dsh": "dsh-v0.1.0-rc.7-events-v3", } task_events = { "codex": (