Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
d66cf37
feat(acp): revive ACP server over WebSocket (#1260)
brettchien Jul 17, 2026
8ab31b6
feat(acp): ACP wire conformance, session/resume, streaming panic-safety
brettchien Jul 17, 2026
68d0748
docs(acp): as-built Phase 1 ADR + official method coverage
brettchien Jul 17, 2026
0c099df
feat(acp): serve /acp from the embedded `openab run` gateway
brettchien Jul 17, 2026
c958331
docs(acp): add Phase 2 browser-control (MCP-over-ACP) design ADR
brettchien Jul 18, 2026
d255e6f
docs(acp): rename the Phase 1 ADR to "base"
brettchien Jul 18, 2026
aef2555
docs(acp): rename browser ADR into the acp-server-websocket family
brettchien Jul 18, 2026
50e95b0
docs(acp): consolidate base + browser ADRs to the re-scoped roadmap
brettchien Jul 18, 2026
0096b22
docs(acp): merge the typing/dependency decision into the base ADR
brettchien Jul 18, 2026
e43d577
feat(acp): add OPENAB_ACP_TRACE frame tracing (both directions)
brettchien Jul 18, 2026
ff6818c
feat(acp): vendor + generate serde-only ACP v1 wire types
brettchien Jul 18, 2026
df4406a
test(acp): conformance guard pinning hand-rolled wire to generated types
brettchien Jul 18, 2026
455164c
docs(acp): update base ADR §7 to as-built (generated types shipped + …
brettchien Jul 18, 2026
99d3c59
docs(acp): record cwd non-propagation as a deliberate security decisi…
brettchien Jul 18, 2026
0b04838
docs(acp): record command-parity verification by construction (§5)
brettchien Jul 18, 2026
9705988
test(acp): add WebSocket /acp e2e smoke script + wire into canary-tests
brettchien Jul 18, 2026
2643349
test(acp): edge-case coverage (emoji/Unicode) + streaming slicer unit…
brettchien Jul 18, 2026
76c6554
fix(format): grapheme-safe, whitespace-preferred message splitting
brettchien Jul 18, 2026
bc46be1
fix(acp-smoke): treat JSON-RPC errors/timeouts as failures (F13)
brettchien Jul 18, 2026
33fded8
docs(acp): correct overclaimed cancel/verification status (F15)
brettchien Jul 18, 2026
dd3dc4e
fix(acp): include acp in embedded-server feature guards (F7)
brettchien Jul 18, 2026
988193e
fix(format): bound over-limit graphemes so every chunk stays <= limit…
brettchien Jul 18, 2026
6728195
fix(acp): trace frames at debug! + truncate, document content logging…
brettchien Jul 18, 2026
a9ea9f0
fix(acp): honor JSON-RPC notification vs request id semantics (F8)
brettchien Jul 18, 2026
6c0c20f
fix(acp): validate required session/new & session/resume params (F9)
brettchien Jul 18, 2026
197eaf3
fix(acp): reject unsupported prompt content blocks instead of droppin…
brettchien Jul 18, 2026
ac50309
fix(acp): lightweight per-connection resource caps (F6)
brettchien Jul 18, 2026
8689e36
test(acp): handler-level tests exercising the real handlers (F14)
brettchien Jul 18, 2026
0d3a74c
fix(acp): deliver long replies whole to avoid truncation (F2)
brettchien Jul 18, 2026
279b045
fix(acp): accept baseline resource_link (F10) + validate/negotiate in…
brettchien Jul 19, 2026
1be48d3
fix(acp): fail closed off loopback without a transport key (F1/F11)
brettchien Jul 19, 2026
99ff7f8
test(acp): strengthen /acp smoke into an item-by-item conformance suite
brettchien Jul 19, 2026
560e5a7
feat(acp): carry the transport bearer key via Sec-WebSocket-Protocol …
brettchien Jul 19, 2026
7e07622
docs(acp): flesh out the tool_call display roadmap item (§6)
brettchien Jul 19, 2026
a53f682
fix(acp): validate JSON-RPC id type (F13), close oversized frames (F7…
brettchien Jul 19, 2026
45375e9
fix(acp): stream append-only answer text, not the re-rendered tool di…
brettchien Jul 19, 2026
c51b0f6
test(acp): add lifecycle e2e checks (cancel, oversized-frame close, h…
brettchien Jul 19, 2026
f4cca5e
fix(acp): enforce resume session cap, fence stale replies, decouple s…
brettchien Jul 19, 2026
acb0bde
fix(smoke): require every emoji/CJK marker + fail-closed on error (R1…
brettchien Jul 21, 2026
70847fe
ci: run acp-gated gateway tests + unified build (R16-F4)
brettchien Jul 21, 2026
eb3cf07
fix(acp): reserve prompt cancel state before spawn — fix prompt→cance…
brettchien Jul 21, 2026
1388591
fix(acp): reject session/resume while a prompt is in flight (R16-F2)
brettchien Jul 21, 2026
c314ece
docs(acp): narrow Phase-1 streaming contract to single terminal chunk…
brettchien Jul 21, 2026
f7c9378
docs(acp): note the busy-gate invariant that keeps prompt cleanup saf…
brettchien Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,14 @@ jobs:
run: cargo clippy --workspace --features unified -- -D warnings
- name: cargo test
run: cargo test --workspace

# gateway tests are now covered by `cargo test --workspace` in the check job above
# (openab-gateway is a workspace member in crates/openab-gateway/)
# `cargo test --workspace` builds with default features, so openab-gateway's `acp`-gated
# conformance/handler/streaming tests never compile or run. Exercise them explicitly, and
# build the unified binary so the embedded /acp endpoint is compiled in CI. Scoped to
# `-p openab-gateway` to avoid the workspace hooks::tests parallel flake.
- name: cargo test (acp gateway)
run: cargo test -p openab-gateway --features acp
- name: cargo build (unified)
run: cargo build --features unified

operator:
needs: changes
Expand Down
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ serenity = { version = "0.12", default-features = false, features = ["client", "
default = ["discord", "slack", "secrets-aws", "agentcore", "config-s3", "pre-seed", "filestore"]

# Opt-in: compile all gateway adapters into a single unified binary
unified = ["telegram", "line", "feishu", "googlechat", "wecom", "teams"]
unified = ["telegram", "line", "feishu", "googlechat", "wecom", "teams", "acp"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F3 — Do not advertise incomplete unified support

This enables ACP in unified builds, but the unified Axum router does not mount /acp and UnifiedGatewayAdapter::dispatch_reply has no "acp" arm, so the feature is unreachable and replies would be dropped.

Requested change: Wire both route and reply dispatch in unified mode, or remove acp from this feature until that integration is complete.


# Core adapters
discord = ["dep:serenity", "openab-core/discord"]
Expand All @@ -48,6 +48,7 @@ feishu = ["dep:openab-gateway", "dep:axum", "openab-gateway/feishu"]
googlechat = ["dep:openab-gateway", "dep:axum", "openab-gateway/googlechat"]
wecom = ["dep:openab-gateway", "dep:axum", "openab-gateway/wecom"]
teams = ["dep:openab-gateway", "dep:axum", "openab-gateway/teams"]
acp = ["dep:openab-gateway", "dep:axum", "openab-gateway/acp"]

[dev-dependencies]
tempfile = "3.27.0"
1 change: 1 addition & 0 deletions crates/openab-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ bytes = "1"
base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
unicode-width = "0.2"
unicode-segmentation = "1"
pulldown-cmark = { version = "0.13", default-features = false }
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"] }
rustls = { version = "0.22", optional = true }
Expand Down
72 changes: 66 additions & 6 deletions crates/openab-core/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,20 @@ pub struct OutputDirectives {
pub reply_to: Option<String>,
}

/// Chunk limit for delivering a reply on `platform`. ACP is a WebSocket transport with
/// no small per-message limit, and its reply route is closed after the first delivered
/// message — so splitting a long reply into multiple messages truncates it over ACP
/// (review F2). ACP therefore delivers whole (`usize::MAX` → a single chunk); every
/// other platform keeps the adapter's chunk limit. Overflow-safe: the only arithmetic on
/// the result is `saturating_sub` (mention-footer reserve).
fn reply_message_limit(platform: &str, adapter_limit: usize) -> usize {
if platform == "acp" {
usize::MAX
} else {
adapter_limit
}
}

/// Parse `[[key:value]]` directives from the beginning of agent output.
/// Returns parsed directives and the remaining content (directives stripped).
pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) {
Expand Down Expand Up @@ -686,8 +700,16 @@ impl AdapterRouter {
) -> Result<()> {
let adapter = adapter.clone();
let thread_channel = thread_channel.clone();
let message_limit = adapter.message_limit();
let streaming = adapter.use_streaming(other_bot_present);
let message_limit = reply_message_limit(&thread_channel.platform, adapter.message_limit());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 F2 — Make ACP streaming completion and replacement explicit

An unbounded final chunk fixes send-once overflow, but it does not fix streaming: placeholder finalization can end with edit_message and no Done, while dummy-draft snapshots can rewrite earlier text during tool/table finalization and corrupt append-only deltas.

Requested change: Carry a request-scoped completion event independent of message commands and define append-versus-replace semantics; test placeholder, rewritten-snapshot, and long send-once paths end to end.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The long-reply truncation is fixed (0d3a74c1, deliver whole). The remaining piece — an explicit correlated end-of-turn signal + edit_message replacement contract — is coupled with backend cancellation (F3) and is tracked as a roadmap follow-up (Review Contract → Follow-ups).

// ACP must not inherit the unified adapter's Telegram streaming flag (wrong
// coupling): it streams append-only `agent_message_chunk` deltas built from the
// post+edit (`edit_message` snapshot) path, i.e. streaming=false. Decide it
// explicitly by platform rather than by whatever Telegram happens to be set to.
let streaming = if thread_channel.platform == "acp" {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 F3 — Preserve progressive ACP updates

For platform == "acp", this forces streaming=false, so the edit/snapshot path is never used and clients normally receive only the final send_message chunk. That does not provide the progressive session/update streaming promised by the PR.

Requested change: Add a dedicated append-only ACP delivery path or narrow the documented contract to final-chunk delivery, with a test proving multiple updates precede the prompt response.

false
} else {
adapter.use_streaming(other_bot_present)
};
// Keep the full turn text (incl. inter-tool narration) when streaming
// (it was already shown live) OR when `[reactions] narration_display` is
// set. Otherwise a send-once turn delivers only the final answer block.
Expand All @@ -706,6 +728,10 @@ impl AdapterRouter {
self.table_mode
};
let tool_display = self.reactions_config.tool_display;
// ACP streams over an append-only `agent_message_chunk`; a re-rendered tool-status
// prefix from `compose_display` would corrupt the deltas, so ACP streams the raw
// append-only answer text and surfaces tools separately (review F2 / roadmap).
let platform_is_acp = thread_channel.platform == "acp";
let prompt_hard_timeout = self.prompt_hard_timeout;
let liveness_check_interval = self.liveness_check_interval;

Expand Down Expand Up @@ -933,7 +959,8 @@ impl AdapterRouter {
}
}
} else if let Some(tx) = &buf_tx {
let _ = tx.send(compose_display(
let _ = tx.send(display_for(
platform_is_acp,
&tool_lines,
&text_buf,
true,
Expand Down Expand Up @@ -982,7 +1009,8 @@ impl AdapterRouter {
}
// Post+edit live update (no-op under native streaming: buf_tx is None).
if let Some(tx) = &buf_tx {
let _ = tx.send(compose_display(
let _ = tx.send(display_for(
platform_is_acp,
&tool_lines,
&text_buf,
true,
Expand Down Expand Up @@ -1027,7 +1055,8 @@ impl AdapterRouter {
});
}
if let Some(tx) = &buf_tx {
let _ = tx.send(compose_display(
let _ = tx.send(display_for(
platform_is_acp,
&tool_lines,
&text_buf,
true,
Expand Down Expand Up @@ -1086,7 +1115,7 @@ impl AdapterRouter {

// Build final content
let final_content =
compose_display(&tool_lines, &text_buf, false, tool_display);
display_for(platform_is_acp, &tool_lines, &text_buf, false, tool_display);
let final_content = if final_content.is_empty() {
if turn_result.is_silent_failure() {
warn!(
Expand Down Expand Up @@ -1474,6 +1503,25 @@ pub(crate) fn classify_empty_turn(
}
}

/// Content to stream/deliver for a reply. ACP gets the raw append-only answer `text`
/// (its `agent_message_chunk` stream is append-only, so a re-rendered `compose_display`
/// tool-status prefix would corrupt the deltas — review F2); tool activity is surfaced
/// separately as structured `tool_call` updates (roadmap). Every other platform gets the
/// tool-merged display.
fn display_for(
platform_is_acp: bool,
tool_lines: &[ToolEntry],
text: &str,
streaming: bool,
tool_display: ToolDisplay,
) -> String {
if platform_is_acp {
text.to_string()
} else {
compose_display(tool_lines, text, streaming, tool_display)
}
}

fn compose_display(
tool_lines: &[ToolEntry],
text: &str,
Expand Down Expand Up @@ -1686,6 +1734,18 @@ fn propagate_mentions_to_chunks(
mod tests {
use super::*;

#[test]
fn acp_reply_limit_is_unbounded_others_use_adapter_limit() {
// ACP delivers whole (no chunking → no truncation, review F2); other platforms
// keep the adapter's limit.
assert_eq!(reply_message_limit("acp", 4096), usize::MAX);
assert_eq!(reply_message_limit("discord", 2000), 2000);
assert_eq!(reply_message_limit("slack", 4096), 4096);
// and a long reply under the ACP limit is a single chunk (delivered whole)
let long = "x".repeat(50_000);
assert_eq!(crate::format::split_message(&long, reply_message_limit("acp", 4096)).len(), 1);
}

#[test]
fn select_delivery_text_send_once_keeps_only_final_block() {
// Simulates: narration "n1" → tool (answer_start→2) → narration "n2"
Expand Down
Loading
Loading