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:
authored and GitHub committed 2026-08-19 06:04:21 +00:00
1 parent 836419bb61
commit 0c2e9b33c7
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、思考或计划正文,不再在任务结束前一直显示空正文。"
}
+103 -4
View File
@@ -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,
&timestamp(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("梳理项目结构"));
}
+2 -2
View File
@@ -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"}
+108 -3
View File
@@ -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" },
+78 -8
View File
@@ -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;
}
+2 -2
View File
@@ -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": (