fix(connections): keep DSH Agent/thinking body in the bubble during tool activity (#49)
## 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`
<!-- apc-engineering-coordination-v1
{"claim_overlap_rejections":1,"merge_tree_conflicts":0,"schema_version":"apc.engineering-coordination.v1","task_created_at":"2026-08-19T05:53:48Z","train_conflict_resolutions":0}
-->
Co-authored-by: gd-ops <gd-ops@xmfunny.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
11 files changed
+472
-19
No files matched your search
@@ -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、思考或计划正文,不再在任务结束前一直显示空正文。"
|
||||
}
|
||||
@@ -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<Option<ContractEvent>
|
||||
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<Option<ContractEvent>
|
||||
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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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("梳理项目结构"));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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"}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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": (
|
||||
|
||||
Reference in new issue
Block a user