coding-bridge 2026.7.2.3__tar.gz → 2026.7.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/PKG-INFO +1 -1
- coding_bridge-2026.7.3.0/coding_bridge/channels/__init__.py +29 -0
- coding_bridge-2026.7.3.0/coding_bridge/channels/base.py +107 -0
- coding_bridge-2026.7.3.0/coding_bridge/channels/dispatcher.py +203 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/pyproject.toml +1 -1
- coding_bridge-2026.7.3.0/tests/test_channels_base.py +91 -0
- coding_bridge-2026.7.3.0/tests/test_channels_dispatcher.py +242 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/.env.example +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/.github/workflows/ci.yaml +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/.github/workflows/publish.yml +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/.gitignore +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/LICENSE +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/NOTICE +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/README.md +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/__init__.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/__main__.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/attachments.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/capabilities.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/cli.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/config.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/connection.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/fs.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/history.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/images.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/locking.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/logs.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/pairing.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/permissions.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/protocol.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/providers/__init__.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/providers/base.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/providers/claude.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/providers/codex.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/providers/copilot.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/session.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/session_meta.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/coding_bridge/store.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/docs/design/reliable-event-delivery.md +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/conftest.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_ask_user_question.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_attachments.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_capabilities.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_codex.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_config.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_connection.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_copilot.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_edit_fork.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_fs.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_history.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_images.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_locking.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_outbox.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_pairing.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_permissions.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_protocol.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_resume_guard.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_session.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_session_meta.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_slash_commands.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_store.py +0 -0
- {coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/tests/test_streaming.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coding-bridge
|
|
3
|
-
Version: 2026.7.
|
|
3
|
+
Version: 2026.7.3.0
|
|
4
4
|
Summary: Node daemon for AceDataCloud Coding Bridge — run Claude Code on your own machine and drive it from the web.
|
|
5
5
|
Project-URL: Homepage, https://github.com/AceDataCloud/CodingBridge
|
|
6
6
|
Project-URL: Issues, https://github.com/AceDataCloud/CodingBridge/issues
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Messaging channels: run a coding-agent turn per inbound external message.
|
|
2
|
+
|
|
3
|
+
Existing coding-bridge shape: browser ⇄ relay WSS ⇄ ``BridgeConnection`` node.
|
|
4
|
+
Channels shape: external messenger (WeChat / Telegram / …) ⇄ ``ChannelAdapter``
|
|
5
|
+
⇄ ``SessionDispatcher`` ⇄ existing ``Session`` + ``Provider``. The relay path is
|
|
6
|
+
untouched; channels are an additive second entry-point built on the same
|
|
7
|
+
``Provider`` layer.
|
|
8
|
+
|
|
9
|
+
See ``plans/coding-bridge-channels/01-production-plan.md`` in AceDataCloud/Index
|
|
10
|
+
for the P1..P11 roadmap this module implements.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .base import (
|
|
14
|
+
ChannelAdapter,
|
|
15
|
+
ChannelTarget,
|
|
16
|
+
IncomingMessage,
|
|
17
|
+
MessageHandler,
|
|
18
|
+
SendResult,
|
|
19
|
+
)
|
|
20
|
+
from .dispatcher import SessionDispatcher
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ChannelAdapter",
|
|
24
|
+
"ChannelTarget",
|
|
25
|
+
"IncomingMessage",
|
|
26
|
+
"MessageHandler",
|
|
27
|
+
"SendResult",
|
|
28
|
+
"SessionDispatcher",
|
|
29
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Channel-adapter Protocol and value objects shared by every messenger.
|
|
2
|
+
|
|
3
|
+
An adapter is the thin glue between one external messaging system (WeChat via
|
|
4
|
+
Wisdom, later Telegram / Discord …) and the dispatcher. It never spawns provider
|
|
5
|
+
processes, tracks sessions, or evaluates prompts — those live in
|
|
6
|
+
``SessionDispatcher`` and reuse the existing ``Session`` / ``Provider`` machinery
|
|
7
|
+
so the browser and WeChat paths share one coding-agent implementation.
|
|
8
|
+
|
|
9
|
+
Design invariants:
|
|
10
|
+
|
|
11
|
+
* ``instance_id`` is opaque to the dispatcher but MUST be stable per adapter
|
|
12
|
+
instance (per configured Wisdom endpoint, per Bot token, …) so multi-instance
|
|
13
|
+
deployments can namespace dedup keys, log fields, and outbox rows.
|
|
14
|
+
* ``send()`` is expected to return a ``SendResult`` — never raise on a delivery
|
|
15
|
+
failure — because delivery is best-effort and the dispatcher records the
|
|
16
|
+
outcome for observability + retry decisions.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections.abc import Awaitable, Callable
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Protocol, runtime_checkable
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ChannelTarget:
|
|
28
|
+
"""Where a reply for an incoming message should be delivered.
|
|
29
|
+
|
|
30
|
+
``conversation_id`` is the ID of the "room" — a WeChat private chat wxid, a
|
|
31
|
+
group room id, a Telegram chat_id, etc. ``reply_to_id`` is set only when the
|
|
32
|
+
external protocol supports a reply-to primitive worth carrying (WeChat
|
|
33
|
+
Wisdom's ``msg_id``, Telegram's ``reply_to_message_id``); adapters that lack
|
|
34
|
+
one leave it ``None``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
conversation_id: str
|
|
38
|
+
conversation_type: str = "private"
|
|
39
|
+
reply_to_id: str | None = None
|
|
40
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class IncomingMessage:
|
|
45
|
+
"""A single external message routed to the dispatcher.
|
|
46
|
+
|
|
47
|
+
``sender_id`` MUST be stable per remote identity (WeChat wxid, Telegram
|
|
48
|
+
user_id, …) — the dispatcher uses it for allowlists, rate limits, and per-
|
|
49
|
+
sender session keying.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
sender_id: str
|
|
53
|
+
sender_name: str | None
|
|
54
|
+
target: ChannelTarget
|
|
55
|
+
text: str
|
|
56
|
+
msg_type: str
|
|
57
|
+
direction: str # ``inbound`` / ``outbound`` (per Wisdom's schema)
|
|
58
|
+
upstream_id: str | None = None
|
|
59
|
+
received_at_ms: int | None = None
|
|
60
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class SendResult:
|
|
65
|
+
"""Outcome of an adapter ``send()`` call. Non-raising by contract."""
|
|
66
|
+
|
|
67
|
+
ok: bool
|
|
68
|
+
upstream_id: str | None = None
|
|
69
|
+
error: str | None = None
|
|
70
|
+
latency_ms: int | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ``handler(msg, adapter)`` — the dispatcher installs one before ``run()``.
|
|
74
|
+
MessageHandler = Callable[["IncomingMessage", "ChannelAdapter"], Awaitable[None]]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@runtime_checkable
|
|
78
|
+
class ChannelAdapter(Protocol):
|
|
79
|
+
"""One long-lived connection to an external messaging system.
|
|
80
|
+
|
|
81
|
+
Lifecycle:
|
|
82
|
+
|
|
83
|
+
1. Construct — parse settings, do NOT open sockets yet.
|
|
84
|
+
2. ``set_handler(h)`` — dispatcher registers the callback.
|
|
85
|
+
3. ``run()`` — subscribe loop; must call ``handler(msg, self)`` per inbound
|
|
86
|
+
message. Should reconnect internally on transient failures. Returns only
|
|
87
|
+
on ``aclose()`` or an unrecoverable auth failure.
|
|
88
|
+
4. ``send(target, text)`` — outbound reply.
|
|
89
|
+
5. ``aclose()`` — signal ``run()`` to stop; idempotent.
|
|
90
|
+
|
|
91
|
+
Adapters MUST NOT retain references to the dispatcher itself — only the
|
|
92
|
+
handler callback — so the dispatcher can outlive individual adapters (and
|
|
93
|
+
vice versa) without leaks.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
name: str
|
|
97
|
+
instance_id: str
|
|
98
|
+
|
|
99
|
+
def set_handler(self, handler: MessageHandler) -> None: ...
|
|
100
|
+
|
|
101
|
+
async def run(self) -> None: ...
|
|
102
|
+
|
|
103
|
+
async def send(
|
|
104
|
+
self, target: ChannelTarget, text: str, *, reply_to: str | None = None
|
|
105
|
+
) -> SendResult: ...
|
|
106
|
+
|
|
107
|
+
async def aclose(self) -> None: ...
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Route external messages to per-session provider turns and post replies back.
|
|
2
|
+
|
|
3
|
+
The dispatcher is the channel-side counterpart of ``BridgeConnection`` — it owns
|
|
4
|
+
the map from ``(instance_id, sender_id, conversation_id)`` to a live ``Session``,
|
|
5
|
+
converts each session's event stream into a single text reply, and hands the
|
|
6
|
+
reply to the originating adapter's ``send()``.
|
|
7
|
+
|
|
8
|
+
Session lifecycle (v1 = one-shot per message):
|
|
9
|
+
|
|
10
|
+
* Every inbound message spawns a fresh ``Session`` and immediately closes it
|
|
11
|
+
when the provider emits ``session.result`` or ``session.error``. Persisting
|
|
12
|
+
context across messages ("resume") is deferred so v1 stays simple and each
|
|
13
|
+
send/receive pair is auditable in isolation.
|
|
14
|
+
|
|
15
|
+
* Concurrency guard: only one in-flight turn per session key. A second message
|
|
16
|
+
arriving before the current turn completes is deferred to the caller (the
|
|
17
|
+
adapter decides whether to buffer, drop, or warn) via a ``BusyError``.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import contextlib
|
|
24
|
+
import logging
|
|
25
|
+
import uuid
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from ..config import Settings
|
|
30
|
+
from ..protocol import Event
|
|
31
|
+
from ..providers.base import ProviderFactory
|
|
32
|
+
from ..session import Session
|
|
33
|
+
from .base import ChannelAdapter, ChannelTarget, IncomingMessage, SendResult
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger("coding-bridge.channels")
|
|
36
|
+
|
|
37
|
+
# ``(adapter_name, instance_id, sender_id, conversation_id)`` — the key namespace
|
|
38
|
+
# the dispatcher uses to route replies. ``adapter_name`` guards against future
|
|
39
|
+
# collisions when a user runs both a WeChat and a Telegram adapter at once.
|
|
40
|
+
SessionKey = tuple[str, str, str, str]
|
|
41
|
+
|
|
42
|
+
# Emitted after a session finishes; carries the concatenated reply text so the
|
|
43
|
+
# adapter can post it. Never raises — the dispatcher swallows adapter errors and
|
|
44
|
+
# logs them so a broken adapter can't kill sibling sessions.
|
|
45
|
+
ReplySink = Callable[[ChannelAdapter, ChannelTarget, str], Awaitable[SendResult]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BusyError(RuntimeError):
|
|
49
|
+
"""Raised by ``handle_message`` when a session key already has an in-flight turn."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _Turn:
|
|
53
|
+
"""Accumulates text_delta / text / result events for one provider turn."""
|
|
54
|
+
|
|
55
|
+
def __init__(self) -> None:
|
|
56
|
+
self._parts: list[str] = []
|
|
57
|
+
self._done = asyncio.Event()
|
|
58
|
+
self._error: str | None = None
|
|
59
|
+
|
|
60
|
+
def on_event(self, payload: dict[str, Any]) -> None:
|
|
61
|
+
event = payload.get("event")
|
|
62
|
+
if event in (Event.SESSION_TEXT, Event.SESSION_TEXT_DELTA):
|
|
63
|
+
text = payload.get("text")
|
|
64
|
+
if isinstance(text, str) and text:
|
|
65
|
+
self._parts.append(text)
|
|
66
|
+
elif event == Event.SESSION_RESULT:
|
|
67
|
+
# Some providers emit their final text only in ``result.text`` and
|
|
68
|
+
# never stream a session.text — pick that up so we don't reply blank.
|
|
69
|
+
result_text = payload.get("text") or payload.get("result")
|
|
70
|
+
if isinstance(result_text, str) and result_text and not self._parts:
|
|
71
|
+
self._parts.append(result_text)
|
|
72
|
+
self._done.set()
|
|
73
|
+
elif event == Event.SESSION_ERROR:
|
|
74
|
+
err = payload.get("message") or payload.get("code") or "provider_error"
|
|
75
|
+
self._error = str(err)
|
|
76
|
+
self._done.set()
|
|
77
|
+
elif event == Event.SESSION_CLOSED:
|
|
78
|
+
self._done.set()
|
|
79
|
+
|
|
80
|
+
def text(self) -> str:
|
|
81
|
+
return "".join(self._parts).strip()
|
|
82
|
+
|
|
83
|
+
def error(self) -> str | None:
|
|
84
|
+
return self._error
|
|
85
|
+
|
|
86
|
+
async def wait(self, timeout: float | None) -> bool:
|
|
87
|
+
try:
|
|
88
|
+
await asyncio.wait_for(self._done.wait(), timeout=timeout)
|
|
89
|
+
except asyncio.TimeoutError:
|
|
90
|
+
return False
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SessionDispatcher:
|
|
95
|
+
"""Fan external messages into ``Session`` turns and fan replies back out."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
settings: Settings,
|
|
100
|
+
provider_factory: ProviderFactory,
|
|
101
|
+
*,
|
|
102
|
+
reply_sink: ReplySink | None = None,
|
|
103
|
+
turn_timeout: float = 300.0,
|
|
104
|
+
default_provider: str = "claude",
|
|
105
|
+
) -> None:
|
|
106
|
+
self.settings = settings
|
|
107
|
+
self._factory = provider_factory
|
|
108
|
+
self._reply_sink = reply_sink or _default_reply_sink
|
|
109
|
+
self._turn_timeout = turn_timeout
|
|
110
|
+
self._default_provider = default_provider
|
|
111
|
+
self._inflight: dict[SessionKey, asyncio.Task[Any]] = {}
|
|
112
|
+
self._lock = asyncio.Lock()
|
|
113
|
+
|
|
114
|
+
def key_for(self, msg: IncomingMessage, adapter: ChannelAdapter) -> SessionKey:
|
|
115
|
+
return (adapter.name, adapter.instance_id, msg.sender_id, msg.target.conversation_id)
|
|
116
|
+
|
|
117
|
+
async def handle_message(self, msg: IncomingMessage, adapter: ChannelAdapter) -> None:
|
|
118
|
+
"""Spawn one turn for ``msg``. Raises ``BusyError`` if a turn is in flight.
|
|
119
|
+
|
|
120
|
+
Non-raising for anything else — a provider crash or send failure is
|
|
121
|
+
logged and reported to the caller only via the returned coroutine
|
|
122
|
+
completing (there is no exception to re-raise on the adapter thread).
|
|
123
|
+
"""
|
|
124
|
+
key = self.key_for(msg, adapter)
|
|
125
|
+
async with self._lock:
|
|
126
|
+
if key in self._inflight and not self._inflight[key].done():
|
|
127
|
+
raise BusyError(f"turn already running for {key!r}")
|
|
128
|
+
task = asyncio.create_task(self._run_turn(key, msg, adapter))
|
|
129
|
+
self._inflight[key] = task
|
|
130
|
+
# Fire-and-forget: the adapter loop must not block on this turn.
|
|
131
|
+
|
|
132
|
+
async def aclose(self) -> None:
|
|
133
|
+
async with self._lock:
|
|
134
|
+
tasks = [t for t in self._inflight.values() if not t.done()]
|
|
135
|
+
self._inflight.clear()
|
|
136
|
+
for t in tasks:
|
|
137
|
+
t.cancel()
|
|
138
|
+
for t in tasks:
|
|
139
|
+
with contextlib.suppress(BaseException):
|
|
140
|
+
await t
|
|
141
|
+
|
|
142
|
+
async def _run_turn(
|
|
143
|
+
self, key: SessionKey, msg: IncomingMessage, adapter: ChannelAdapter
|
|
144
|
+
) -> None:
|
|
145
|
+
session_id = uuid.uuid4().hex
|
|
146
|
+
turn = _Turn()
|
|
147
|
+
|
|
148
|
+
async def emit(payload: dict[str, Any]) -> None:
|
|
149
|
+
turn.on_event(payload)
|
|
150
|
+
|
|
151
|
+
session = Session(
|
|
152
|
+
session_id=session_id,
|
|
153
|
+
provider_factory=self._factory,
|
|
154
|
+
emit=emit,
|
|
155
|
+
settings=self.settings,
|
|
156
|
+
cwd=self.settings.default_cwd,
|
|
157
|
+
model=self.settings.default_model,
|
|
158
|
+
permission_mode="default",
|
|
159
|
+
provider=self._default_provider,
|
|
160
|
+
)
|
|
161
|
+
try:
|
|
162
|
+
await session.start(msg.text)
|
|
163
|
+
ok = await turn.wait(self._turn_timeout)
|
|
164
|
+
reply = turn.text()
|
|
165
|
+
# Error takes precedence over any text already streamed — if the
|
|
166
|
+
# provider emits SESSION_RESULT and then SESSION_ERROR (buggy
|
|
167
|
+
# provider), we surface the error instead of silently returning the
|
|
168
|
+
# stale text.
|
|
169
|
+
if turn.error():
|
|
170
|
+
reply = f"(provider error: {turn.error()})"
|
|
171
|
+
elif not ok:
|
|
172
|
+
reply = reply or "(provider timed out; no reply)"
|
|
173
|
+
if not reply:
|
|
174
|
+
reply = "(no reply)"
|
|
175
|
+
try:
|
|
176
|
+
result = await self._reply_sink(adapter, msg.target, reply)
|
|
177
|
+
except Exception as exc: # defensive: adapter send should not kill dispatcher
|
|
178
|
+
logger.exception("channel send failed: %s", exc)
|
|
179
|
+
return
|
|
180
|
+
if not result.ok:
|
|
181
|
+
logger.warning(
|
|
182
|
+
"channel send returned failure: adapter=%s instance=%s error=%s",
|
|
183
|
+
adapter.name,
|
|
184
|
+
adapter.instance_id,
|
|
185
|
+
result.error,
|
|
186
|
+
)
|
|
187
|
+
finally:
|
|
188
|
+
with contextlib.suppress(BaseException):
|
|
189
|
+
await session.close()
|
|
190
|
+
async with self._lock:
|
|
191
|
+
if self._inflight.get(key) is asyncio.current_task():
|
|
192
|
+
self._inflight.pop(key, None)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
async def _default_reply_sink(
|
|
196
|
+
adapter: ChannelAdapter, target: ChannelTarget, text: str
|
|
197
|
+
) -> SendResult:
|
|
198
|
+
"""Fallback sink used when the caller didn't supply one — just calls send()."""
|
|
199
|
+
|
|
200
|
+
return await adapter.send(target, text)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
__all__ = ["SessionDispatcher", "BusyError", "ReplySink"]
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "coding-bridge"
|
|
7
|
-
version = "2026.7.
|
|
7
|
+
version = "2026.7.3.0"
|
|
8
8
|
description = "Node daemon for AceDataCloud Coding Bridge — run Claude Code on your own machine and drive it from the web."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""P1: ChannelAdapter Protocol conformance + value-object smoke tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import FrozenInstanceError
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from coding_bridge.channels import (
|
|
11
|
+
ChannelAdapter,
|
|
12
|
+
ChannelTarget,
|
|
13
|
+
IncomingMessage,
|
|
14
|
+
SendResult,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _FakeAdapter:
|
|
19
|
+
"""Minimal ChannelAdapter — used only to verify runtime_checkable Protocol."""
|
|
20
|
+
|
|
21
|
+
name = "fake"
|
|
22
|
+
instance_id = "inst-1"
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.sent: list[tuple[ChannelTarget, str, str | None]] = []
|
|
26
|
+
self.closed = False
|
|
27
|
+
|
|
28
|
+
def set_handler(self, handler): # noqa: ANN001 — test double
|
|
29
|
+
self.handler = handler
|
|
30
|
+
|
|
31
|
+
async def run(self) -> None:
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
async def send(self, target, text, *, reply_to=None): # noqa: ANN001
|
|
35
|
+
self.sent.append((target, text, reply_to))
|
|
36
|
+
return SendResult(ok=True, upstream_id="u1", latency_ms=1)
|
|
37
|
+
|
|
38
|
+
async def aclose(self) -> None:
|
|
39
|
+
self.closed = True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_channel_adapter_protocol_conformance() -> None:
|
|
43
|
+
"""A minimal concrete adapter satisfies the runtime-checkable Protocol."""
|
|
44
|
+
|
|
45
|
+
adapter = _FakeAdapter()
|
|
46
|
+
assert isinstance(adapter, ChannelAdapter)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_channel_target_is_frozen_and_defaults_private() -> None:
|
|
50
|
+
target = ChannelTarget(conversation_id="wxid_abc")
|
|
51
|
+
assert target.conversation_type == "private"
|
|
52
|
+
assert target.reply_to_id is None
|
|
53
|
+
assert target.extra == {}
|
|
54
|
+
with pytest.raises(FrozenInstanceError):
|
|
55
|
+
target.conversation_id = "other" # type: ignore[misc]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_incoming_message_is_frozen() -> None:
|
|
59
|
+
msg = IncomingMessage(
|
|
60
|
+
sender_id="wxid_sender",
|
|
61
|
+
sender_name="Alice",
|
|
62
|
+
target=ChannelTarget(conversation_id="wxid_room"),
|
|
63
|
+
text="/ask hi",
|
|
64
|
+
msg_type="text",
|
|
65
|
+
direction="inbound",
|
|
66
|
+
upstream_id="msg-1",
|
|
67
|
+
)
|
|
68
|
+
assert msg.sender_id == "wxid_sender"
|
|
69
|
+
assert msg.direction == "inbound"
|
|
70
|
+
with pytest.raises(FrozenInstanceError):
|
|
71
|
+
msg.text = "hijacked" # type: ignore[misc]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_send_result_defaults() -> None:
|
|
75
|
+
r = SendResult(ok=False, error="429 too many")
|
|
76
|
+
assert r.ok is False
|
|
77
|
+
assert r.upstream_id is None
|
|
78
|
+
assert r.error == "429 too many"
|
|
79
|
+
assert r.latency_ms is None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_send_returns_send_result() -> None:
|
|
83
|
+
"""Contract: send() returns a SendResult, never raises for delivery failure."""
|
|
84
|
+
|
|
85
|
+
async def _go() -> None:
|
|
86
|
+
adapter = _FakeAdapter()
|
|
87
|
+
r = await adapter.send(ChannelTarget(conversation_id="c1"), "hi")
|
|
88
|
+
assert isinstance(r, SendResult)
|
|
89
|
+
assert r.ok is True
|
|
90
|
+
|
|
91
|
+
asyncio.run(_go())
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""P1: SessionDispatcher — fan external messages into provider turns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from coding_bridge.channels import (
|
|
11
|
+
ChannelTarget,
|
|
12
|
+
IncomingMessage,
|
|
13
|
+
SendResult,
|
|
14
|
+
SessionDispatcher,
|
|
15
|
+
)
|
|
16
|
+
from coding_bridge.channels.dispatcher import BusyError
|
|
17
|
+
from coding_bridge.config import Settings
|
|
18
|
+
from coding_bridge.protocol import Event, event_payload
|
|
19
|
+
|
|
20
|
+
# 5 s ceiling gives slow CI runners headroom while keeping local test runs fast.
|
|
21
|
+
_POLL_ITERATIONS = 1000
|
|
22
|
+
_POLL_INTERVAL_S = 0.005
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _wait_until(pred, iterations: int = _POLL_ITERATIONS) -> None:
|
|
26
|
+
for _ in range(iterations):
|
|
27
|
+
if pred():
|
|
28
|
+
return
|
|
29
|
+
await asyncio.sleep(_POLL_INTERVAL_S)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _StubProvider:
|
|
33
|
+
"""A provider whose ``start()`` emits scripted events and completes."""
|
|
34
|
+
|
|
35
|
+
name = "stub"
|
|
36
|
+
|
|
37
|
+
def __init__(self, session_id: str, emit, _ask, script: list[dict[str, Any]] | None = None):
|
|
38
|
+
self._session_id = session_id
|
|
39
|
+
self._emit = emit
|
|
40
|
+
self._script = (
|
|
41
|
+
script
|
|
42
|
+
if script is not None
|
|
43
|
+
else [
|
|
44
|
+
event_payload(Event.SESSION_TEXT, session_id, text="hello, "),
|
|
45
|
+
event_payload(Event.SESSION_TEXT, session_id, text="world"),
|
|
46
|
+
event_payload(Event.SESSION_RESULT, session_id, text="hello, world"),
|
|
47
|
+
]
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def start(self, _prompt, **_kw):
|
|
51
|
+
for payload in self._script:
|
|
52
|
+
await self._emit(payload)
|
|
53
|
+
|
|
54
|
+
async def send(self, _prompt, **_kw):
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
async def edit(self, *_a, **_kw):
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
async def interrupt(self):
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
async def aclose(self):
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _factory(script=None):
|
|
68
|
+
def _make(_provider_name, session_id, emit, ask):
|
|
69
|
+
return _StubProvider(session_id, emit, ask, script=script)
|
|
70
|
+
|
|
71
|
+
return _make
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _RecordingAdapter:
|
|
75
|
+
name = "wechat"
|
|
76
|
+
instance_id = "beijing-cvm"
|
|
77
|
+
|
|
78
|
+
def __init__(self) -> None:
|
|
79
|
+
self.replies: list[tuple[str, str]] = []
|
|
80
|
+
self._handler = None
|
|
81
|
+
|
|
82
|
+
def set_handler(self, h):
|
|
83
|
+
self._handler = h
|
|
84
|
+
|
|
85
|
+
async def run(self):
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
async def send(self, target: ChannelTarget, text: str, *, reply_to=None) -> SendResult:
|
|
89
|
+
self.replies.append((target.conversation_id, text))
|
|
90
|
+
return SendResult(ok=True, upstream_id="upstream-1", latency_ms=42)
|
|
91
|
+
|
|
92
|
+
async def aclose(self):
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _msg(sender: str = "wxid_a", conv: str = "wxid_a", text: str = "/ask hi") -> IncomingMessage:
|
|
97
|
+
return IncomingMessage(
|
|
98
|
+
sender_id=sender,
|
|
99
|
+
sender_name=None,
|
|
100
|
+
target=ChannelTarget(conversation_id=conv),
|
|
101
|
+
text=text,
|
|
102
|
+
msg_type="text",
|
|
103
|
+
direction="inbound",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.mark.asyncio
|
|
108
|
+
async def test_handle_message_concatenates_text_and_sends_reply(tmp_path):
|
|
109
|
+
settings = Settings(config_dir=tmp_path)
|
|
110
|
+
dispatcher = SessionDispatcher(settings, provider_factory=_factory(), default_provider="stub")
|
|
111
|
+
adapter = _RecordingAdapter()
|
|
112
|
+
|
|
113
|
+
await dispatcher.handle_message(_msg(text="/ask ping"), adapter)
|
|
114
|
+
await _wait_until(lambda: bool(adapter.replies))
|
|
115
|
+
|
|
116
|
+
assert adapter.replies == [("wxid_a", "hello, world")]
|
|
117
|
+
await dispatcher.aclose()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.asyncio
|
|
121
|
+
async def test_busy_error_when_second_message_for_same_key_arrives(tmp_path):
|
|
122
|
+
"""A second message for the same (sender, conversation) while a turn is in flight raises."""
|
|
123
|
+
|
|
124
|
+
barrier = asyncio.Event()
|
|
125
|
+
|
|
126
|
+
class _SlowProvider(_StubProvider):
|
|
127
|
+
async def start(self, _prompt, **_kw):
|
|
128
|
+
await barrier.wait()
|
|
129
|
+
await self._emit(event_payload(Event.SESSION_RESULT, self._session_id, text="ok"))
|
|
130
|
+
|
|
131
|
+
def _slow_factory(_name, session_id, emit, ask):
|
|
132
|
+
return _SlowProvider(session_id, emit, ask)
|
|
133
|
+
|
|
134
|
+
settings = Settings(config_dir=tmp_path)
|
|
135
|
+
dispatcher = SessionDispatcher(
|
|
136
|
+
settings, provider_factory=_slow_factory, default_provider="stub"
|
|
137
|
+
)
|
|
138
|
+
adapter = _RecordingAdapter()
|
|
139
|
+
|
|
140
|
+
await dispatcher.handle_message(_msg(), adapter)
|
|
141
|
+
with pytest.raises(BusyError):
|
|
142
|
+
await dispatcher.handle_message(_msg(), adapter)
|
|
143
|
+
barrier.set()
|
|
144
|
+
await _wait_until(lambda: bool(adapter.replies))
|
|
145
|
+
assert adapter.replies == [("wxid_a", "ok")]
|
|
146
|
+
await dispatcher.aclose()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.asyncio
|
|
150
|
+
async def test_different_conversations_run_concurrently(tmp_path):
|
|
151
|
+
"""Two messages from different conversations get independent turns."""
|
|
152
|
+
|
|
153
|
+
settings = Settings(config_dir=tmp_path)
|
|
154
|
+
dispatcher = SessionDispatcher(settings, provider_factory=_factory(), default_provider="stub")
|
|
155
|
+
adapter = _RecordingAdapter()
|
|
156
|
+
|
|
157
|
+
await dispatcher.handle_message(_msg(sender="a", conv="a", text="/ask 1"), adapter)
|
|
158
|
+
await dispatcher.handle_message(_msg(sender="b", conv="b", text="/ask 2"), adapter)
|
|
159
|
+
await _wait_until(lambda: len(adapter.replies) >= 2)
|
|
160
|
+
|
|
161
|
+
convs = sorted(c for c, _ in adapter.replies)
|
|
162
|
+
assert convs == ["a", "b"]
|
|
163
|
+
await dispatcher.aclose()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@pytest.mark.asyncio
|
|
167
|
+
async def test_reply_sink_override_receives_final_text(tmp_path):
|
|
168
|
+
"""A caller-supplied reply_sink is used in preference to adapter.send()."""
|
|
169
|
+
|
|
170
|
+
settings = Settings(config_dir=tmp_path)
|
|
171
|
+
captured: list[tuple[str, str]] = []
|
|
172
|
+
|
|
173
|
+
async def _sink(adapter, target, text):
|
|
174
|
+
captured.append((adapter.name, text))
|
|
175
|
+
return SendResult(ok=True)
|
|
176
|
+
|
|
177
|
+
dispatcher = SessionDispatcher(
|
|
178
|
+
settings,
|
|
179
|
+
provider_factory=_factory(),
|
|
180
|
+
reply_sink=_sink,
|
|
181
|
+
default_provider="stub",
|
|
182
|
+
)
|
|
183
|
+
adapter = _RecordingAdapter()
|
|
184
|
+
await dispatcher.handle_message(_msg(text="/ask hi"), adapter)
|
|
185
|
+
await _wait_until(lambda: bool(captured))
|
|
186
|
+
|
|
187
|
+
assert captured == [("wechat", "hello, world")]
|
|
188
|
+
assert adapter.replies == [] # sink bypassed .send()
|
|
189
|
+
await dispatcher.aclose()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@pytest.mark.asyncio
|
|
193
|
+
async def test_falls_back_to_no_reply_when_provider_emits_nothing(tmp_path):
|
|
194
|
+
"""Provider that never emits any event → dispatcher timeout path fires."""
|
|
195
|
+
|
|
196
|
+
empty_script: list[dict[str, Any]] = []
|
|
197
|
+
|
|
198
|
+
async def _sink(_adapter, _target, text):
|
|
199
|
+
_sink.captured = text # type: ignore[attr-defined]
|
|
200
|
+
return SendResult(ok=True)
|
|
201
|
+
|
|
202
|
+
_sink.captured = None # type: ignore[attr-defined]
|
|
203
|
+
|
|
204
|
+
settings = Settings(config_dir=tmp_path)
|
|
205
|
+
dispatcher = SessionDispatcher(
|
|
206
|
+
settings,
|
|
207
|
+
provider_factory=_factory(script=empty_script),
|
|
208
|
+
reply_sink=_sink,
|
|
209
|
+
turn_timeout=0.1,
|
|
210
|
+
default_provider="stub",
|
|
211
|
+
)
|
|
212
|
+
adapter = _RecordingAdapter()
|
|
213
|
+
|
|
214
|
+
await dispatcher.handle_message(_msg(), adapter)
|
|
215
|
+
await _wait_until(lambda: _sink.captured is not None) # type: ignore[attr-defined]
|
|
216
|
+
|
|
217
|
+
assert "timed out" in (_sink.captured or "") # type: ignore[attr-defined]
|
|
218
|
+
await dispatcher.aclose()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@pytest.mark.asyncio
|
|
222
|
+
async def test_provider_error_takes_precedence_over_result_text(tmp_path):
|
|
223
|
+
"""A buggy provider emitting SESSION_RESULT then SESSION_ERROR must surface the error."""
|
|
224
|
+
|
|
225
|
+
def _factory_err(_name, session_id, emit, _ask):
|
|
226
|
+
async def _start(_prompt, **_kw):
|
|
227
|
+
await emit(event_payload(Event.SESSION_RESULT, session_id, text="stale"))
|
|
228
|
+
await emit(event_payload(Event.SESSION_ERROR, session_id, message="boom"))
|
|
229
|
+
|
|
230
|
+
prov = _StubProvider(session_id, emit, _ask, script=[])
|
|
231
|
+
prov.start = _start # type: ignore[method-assign]
|
|
232
|
+
return prov
|
|
233
|
+
|
|
234
|
+
settings = Settings(config_dir=tmp_path)
|
|
235
|
+
dispatcher = SessionDispatcher(settings, provider_factory=_factory_err, default_provider="stub")
|
|
236
|
+
adapter = _RecordingAdapter()
|
|
237
|
+
|
|
238
|
+
await dispatcher.handle_message(_msg(), adapter)
|
|
239
|
+
await _wait_until(lambda: bool(adapter.replies))
|
|
240
|
+
|
|
241
|
+
assert adapter.replies == [("wxid_a", "(provider error: boom)")]
|
|
242
|
+
await dispatcher.aclose()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{coding_bridge-2026.7.2.3 → coding_bridge-2026.7.3.0}/docs/design/reliable-event-delivery.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|