coding-bridge 2026.7.2.0__tar.gz → 2026.7.2.2__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.0 → coding_bridge-2026.7.2.2}/PKG-INFO +1 -1
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/connection.py +12 -4
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/history.py +247 -5
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/pyproject.toml +1 -1
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_history.py +165 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/.env.example +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/.github/workflows/ci.yaml +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/.github/workflows/publish.yml +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/.gitignore +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/LICENSE +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/NOTICE +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/README.md +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/__init__.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/__main__.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/attachments.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/capabilities.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/cli.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/config.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/fs.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/images.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/locking.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/logs.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/pairing.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/permissions.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/protocol.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/providers/__init__.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/providers/base.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/providers/claude.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/providers/codex.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/providers/copilot.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/session.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/session_meta.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/coding_bridge/store.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/docs/design/reliable-event-delivery.md +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/conftest.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_ask_user_question.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_attachments.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_capabilities.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_codex.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_config.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_connection.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_copilot.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_edit_fork.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_fs.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_images.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_locking.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_outbox.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_pairing.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_permissions.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_protocol.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_resume_guard.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_session.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_session_meta.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_slash_commands.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/tests/test_store.py +0 -0
- {coding_bridge-2026.7.2.0 → coding_bridge-2026.7.2.2}/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.2.
|
|
3
|
+
Version: 2026.7.2.2
|
|
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
|
|
@@ -334,6 +334,16 @@ class BridgeConnection:
|
|
|
334
334
|
**_session_overrides(payload),
|
|
335
335
|
)
|
|
336
336
|
return
|
|
337
|
+
resume = payload.get("resume_session_id") or None
|
|
338
|
+
prompt = payload.get("prompt", "")
|
|
339
|
+
# Continue a Copilot session the CLI can't natively resume (e.g. a VS Code
|
|
340
|
+
# Copilot Chat session opened from history): seed its transcript into a
|
|
341
|
+
# fresh session instead of a doomed session/resume.
|
|
342
|
+
if provider == "copilot" and resume and not history.copilot_native(resume):
|
|
343
|
+
seed = await asyncio.to_thread(history.build_seed, resume)
|
|
344
|
+
if seed:
|
|
345
|
+
prompt = f"{seed}\n\n{prompt}".strip() if prompt else seed
|
|
346
|
+
resume = None
|
|
337
347
|
session = Session(
|
|
338
348
|
session_id,
|
|
339
349
|
self.provider_factory,
|
|
@@ -344,14 +354,12 @@ class BridgeConnection:
|
|
|
344
354
|
permission_mode=payload.get("permission_mode") or "default",
|
|
345
355
|
provider=provider,
|
|
346
356
|
effort=payload.get("effort") or None,
|
|
347
|
-
resume=
|
|
357
|
+
resume=resume,
|
|
348
358
|
trace_id=payload.get("trace_id"),
|
|
349
359
|
on_rekey=self._rekey_session,
|
|
350
360
|
)
|
|
351
361
|
self.sessions[session_id] = session
|
|
352
|
-
await session.start(
|
|
353
|
-
payload.get("prompt", ""), payload.get("images"), payload.get("attachments")
|
|
354
|
-
)
|
|
362
|
+
await session.start(prompt, payload.get("images"), payload.get("attachments"))
|
|
355
363
|
|
|
356
364
|
def _session(self, session_id: str | None) -> Session | None:
|
|
357
365
|
"""Resolve a session by its canonical id or a still-live provisional alias."""
|
|
@@ -20,6 +20,7 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
20
20
|
from datetime import datetime
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
from typing import Any
|
|
23
|
+
from urllib.parse import unquote, urlparse
|
|
23
24
|
|
|
24
25
|
# Module-level roots so tests can point them at fixtures via monkeypatch.
|
|
25
26
|
CLAUDE_ROOT = Path.home() / ".claude" / "projects"
|
|
@@ -29,6 +30,33 @@ COPILOT_ROOT = (
|
|
|
29
30
|
Path(os.environ.get("COPILOT_HOME") or str(Path.home() / ".copilot")) / "session-state"
|
|
30
31
|
)
|
|
31
32
|
|
|
33
|
+
|
|
34
|
+
def _vscode_chat_roots() -> list[Path]:
|
|
35
|
+
"""Candidate ``workspaceStorage`` dirs for VS Code Copilot Chat transcripts.
|
|
36
|
+
|
|
37
|
+
Chat sessions live at ``<storage>/<workspace-hash>/chatSessions/<id>.jsonl``.
|
|
38
|
+
``VSCODE_CHAT_HOME`` overrides for tests / non-standard installs.
|
|
39
|
+
"""
|
|
40
|
+
override = os.environ.get("VSCODE_CHAT_HOME")
|
|
41
|
+
if override:
|
|
42
|
+
return [Path(override)]
|
|
43
|
+
home = Path.home()
|
|
44
|
+
bases: list[Path | None] = [
|
|
45
|
+
home / "Library" / "Application Support", # macOS
|
|
46
|
+
Path(os.environ["APPDATA"]) if os.environ.get("APPDATA") else None, # Windows
|
|
47
|
+
home / ".config", # Linux
|
|
48
|
+
]
|
|
49
|
+
apps = ("Code", "Code - Insiders", "VSCodium", "Cursor")
|
|
50
|
+
roots: list[Path] = []
|
|
51
|
+
for base in bases:
|
|
52
|
+
if base is None:
|
|
53
|
+
continue
|
|
54
|
+
roots.extend(base / app / "User" / "workspaceStorage" for app in apps)
|
|
55
|
+
return roots
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
VSCODE_CHAT_ROOTS = _vscode_chat_roots()
|
|
59
|
+
|
|
32
60
|
_TITLE_MAX = 80
|
|
33
61
|
_DETAIL_MAX_EVENTS = 4000
|
|
34
62
|
|
|
@@ -42,9 +70,14 @@ _UUID_RE = re.compile(
|
|
|
42
70
|
|
|
43
71
|
# --- public API ------------------------------------------------------------
|
|
44
72
|
def list_sessions(limit: int = 200) -> list[dict[str, Any]]:
|
|
45
|
-
"""Return session summaries from
|
|
73
|
+
"""Return session summaries from every provider, newest first."""
|
|
46
74
|
limit = max(1, min(int(limit or 200), 1000))
|
|
47
|
-
sessions =
|
|
75
|
+
sessions = (
|
|
76
|
+
_list_claude(limit)
|
|
77
|
+
+ _list_codex(limit)
|
|
78
|
+
+ _list_copilot(limit)
|
|
79
|
+
+ _list_vscode_chat(limit)
|
|
80
|
+
)
|
|
48
81
|
sessions.sort(key=lambda s: s.get("updated_at") or 0, reverse=True)
|
|
49
82
|
return sessions[:limit]
|
|
50
83
|
|
|
@@ -98,6 +131,54 @@ def claude_user_uuid_after(session_id: str, cut_uuid: str | None) -> str | None:
|
|
|
98
131
|
return None
|
|
99
132
|
|
|
100
133
|
|
|
134
|
+
# Cap the seeded transcript so continuing a huge conversation doesn't blow the
|
|
135
|
+
# first prompt; keep the most recent turns (the live context).
|
|
136
|
+
_SEED_MAX_CHARS = 40000
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def copilot_native(session_id: str) -> bool:
|
|
140
|
+
"""True if a Copilot session id belongs to the CLI store (natively resumable).
|
|
141
|
+
|
|
142
|
+
VS Code Copilot Chat sessions live in a different store the CLI can't resume,
|
|
143
|
+
so they must be continued by seeding the transcript into a fresh session.
|
|
144
|
+
"""
|
|
145
|
+
return _copilot_path(session_id) is not None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def build_seed(session_id: str) -> str:
|
|
149
|
+
"""Render a Copilot transcript as a compact preamble to continue it elsewhere.
|
|
150
|
+
|
|
151
|
+
Used when a session can't be natively resumed (e.g. a VS Code Copilot Chat
|
|
152
|
+
session continued from the phone): the prior user/assistant turns are replayed
|
|
153
|
+
as context in a fresh session. Tool calls and thinking are omitted; only typed
|
|
154
|
+
prompts and assistant text are kept, tail-capped at ``_SEED_MAX_CHARS``.
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
detail = read_session("copilot", session_id)
|
|
158
|
+
except (FileNotFoundError, OSError, ValueError):
|
|
159
|
+
return ""
|
|
160
|
+
lines: list[str] = []
|
|
161
|
+
for event in detail.get("events", []):
|
|
162
|
+
kind = event.get("kind")
|
|
163
|
+
text = event.get("text")
|
|
164
|
+
if not isinstance(text, str) or not text.strip():
|
|
165
|
+
continue
|
|
166
|
+
if kind == "prompt":
|
|
167
|
+
lines.append(f"User: {text.strip()}")
|
|
168
|
+
elif kind == "text":
|
|
169
|
+
lines.append(f"Assistant: {text.strip()}")
|
|
170
|
+
if not lines:
|
|
171
|
+
return ""
|
|
172
|
+
body = "\n\n".join(lines)
|
|
173
|
+
if len(body) > _SEED_MAX_CHARS:
|
|
174
|
+
body = "…(earlier turns omitted)…\n\n" + body[-_SEED_MAX_CHARS:]
|
|
175
|
+
return (
|
|
176
|
+
"[Continuing a previous conversation. Transcript so far:]\n\n"
|
|
177
|
+
+ body
|
|
178
|
+
+ "\n\n[End of previous transcript — continue from here.]"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
101
182
|
def read_session(provider: str, session_id: str) -> dict[str, Any]:
|
|
102
183
|
"""Return a normalised transcript ``{provider,title,cwd,model,...,events}``."""
|
|
103
184
|
if provider == "claude":
|
|
@@ -111,10 +192,17 @@ def read_session(provider: str, session_id: str) -> dict[str, Any]:
|
|
|
111
192
|
raise FileNotFoundError(f"codex session not found: {session_id}")
|
|
112
193
|
header, events = _codex_read(path, _codex_index())
|
|
113
194
|
elif provider == "copilot":
|
|
195
|
+
# A "copilot" session may live in the CLI store or the VS Code Copilot
|
|
196
|
+
# Chat store — both are Copilot to the user. Prefer the CLI store, fall
|
|
197
|
+
# back to the VS Code panel transcript.
|
|
114
198
|
path = _copilot_path(session_id)
|
|
115
|
-
if path is None:
|
|
116
|
-
|
|
117
|
-
|
|
199
|
+
if path is not None:
|
|
200
|
+
header, events = _copilot_read(path)
|
|
201
|
+
else:
|
|
202
|
+
vpath = _vscode_chat_path(session_id)
|
|
203
|
+
if vpath is None:
|
|
204
|
+
raise FileNotFoundError(f"copilot session not found: {session_id}")
|
|
205
|
+
header, events = _vscode_chat_read(vpath)
|
|
118
206
|
else:
|
|
119
207
|
raise ValueError(f"unknown provider: {provider}")
|
|
120
208
|
# Keep the MOST RECENT events when a transcript is huge — the tail is the
|
|
@@ -625,6 +713,160 @@ def _copilot_tool_result_text(data: dict[str, Any]) -> Any:
|
|
|
625
713
|
return _stringify(error)
|
|
626
714
|
|
|
627
715
|
|
|
716
|
+
# --- VS Code Copilot Chat --------------------------------------------------
|
|
717
|
+
# The panel stores each session as an append-only delta log of `{kind, v}` lines
|
|
718
|
+
# (format v3). We don't replay the deltas; we recursively find the request
|
|
719
|
+
# objects (dict with `message.text` + `response`), which is resilient to delta
|
|
720
|
+
# opcode changes across VS Code releases. These are surfaced as ordinary
|
|
721
|
+
# `copilot` sessions (same backend); read-only, so continuing makes a copy.
|
|
722
|
+
def _list_vscode_chat(limit: int) -> list[dict[str, Any]]:
|
|
723
|
+
files: list[Path] = []
|
|
724
|
+
for root in VSCODE_CHAT_ROOTS:
|
|
725
|
+
if root.exists():
|
|
726
|
+
files.extend(root.glob("*/chatSessions/*.jsonl"))
|
|
727
|
+
files = sorted(files, key=_safe_mtime, reverse=True)[:limit]
|
|
728
|
+
return _summaries(files, _vscode_chat_summary)
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _vscode_collect_requests(obj: Any, out: list[dict[str, Any]]) -> None:
|
|
732
|
+
"""Collect chat-request objects, pruning descent into the huge `response`."""
|
|
733
|
+
if isinstance(obj, dict):
|
|
734
|
+
msg = obj.get("message")
|
|
735
|
+
if isinstance(msg, dict) and "text" in msg and "response" in obj:
|
|
736
|
+
out.append(obj)
|
|
737
|
+
return # don't recurse into this request's response array
|
|
738
|
+
for value in obj.values():
|
|
739
|
+
_vscode_collect_requests(value, out)
|
|
740
|
+
elif isinstance(obj, list):
|
|
741
|
+
for value in obj:
|
|
742
|
+
_vscode_collect_requests(value, out)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _vscode_requests(path: Path) -> list[dict[str, Any]]:
|
|
746
|
+
seen: dict[str, dict[str, Any]] = {}
|
|
747
|
+
ordered: list[dict[str, Any]] = []
|
|
748
|
+
for rec in _iter_jsonl(path):
|
|
749
|
+
found: list[dict[str, Any]] = []
|
|
750
|
+
_vscode_collect_requests(rec.get("v"), found)
|
|
751
|
+
for req in found:
|
|
752
|
+
rid = req.get("requestId")
|
|
753
|
+
key = rid if isinstance(rid, str) else str(id(req))
|
|
754
|
+
if key not in seen:
|
|
755
|
+
seen[key] = req
|
|
756
|
+
ordered.append(req)
|
|
757
|
+
ordered.sort(key=lambda r: r.get("timestamp") or 0)
|
|
758
|
+
return ordered
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _vscode_user_text(req: dict[str, Any]) -> str:
|
|
762
|
+
msg = req.get("message")
|
|
763
|
+
text = msg.get("text") if isinstance(msg, dict) else None
|
|
764
|
+
return text.strip() if isinstance(text, str) else ""
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _vscode_chat_summary(path: Path) -> dict[str, Any]:
|
|
768
|
+
reqs = _vscode_requests(path)
|
|
769
|
+
title = ""
|
|
770
|
+
updated_at = _mtime_ms(path)
|
|
771
|
+
for req in reqs:
|
|
772
|
+
ts = req.get("timestamp")
|
|
773
|
+
if isinstance(ts, int):
|
|
774
|
+
updated_at = max(updated_at, ts)
|
|
775
|
+
if not title:
|
|
776
|
+
text = _vscode_user_text(req)
|
|
777
|
+
if text and not _is_context_noise(text):
|
|
778
|
+
title = _clean_title(text)
|
|
779
|
+
return {
|
|
780
|
+
"provider": "copilot",
|
|
781
|
+
"session_id": path.stem,
|
|
782
|
+
"title": title or "(no prompt)",
|
|
783
|
+
"cwd": _vscode_cwd(path),
|
|
784
|
+
"git_branch": None,
|
|
785
|
+
"updated_at": updated_at,
|
|
786
|
+
"message_count": len(reqs),
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _vscode_chat_read(path: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
791
|
+
header: dict[str, Any] = {
|
|
792
|
+
"cwd": _vscode_cwd(path),
|
|
793
|
+
"git_branch": None,
|
|
794
|
+
"model": None,
|
|
795
|
+
"title": "",
|
|
796
|
+
}
|
|
797
|
+
events: list[dict[str, Any]] = []
|
|
798
|
+
for req in _vscode_requests(path):
|
|
799
|
+
ts = req.get("timestamp") if isinstance(req.get("timestamp"), int) else None
|
|
800
|
+
model = req.get("modelId")
|
|
801
|
+
if header["model"] is None and isinstance(model, str) and model:
|
|
802
|
+
header["model"] = model
|
|
803
|
+
text = _vscode_user_text(req)
|
|
804
|
+
if text and not _is_context_noise(text):
|
|
805
|
+
events.append({"kind": "prompt", "text": text, "ts": ts})
|
|
806
|
+
if not header["title"]:
|
|
807
|
+
header["title"] = _clean_title(text)
|
|
808
|
+
_vscode_response_events(req.get("response"), ts, events)
|
|
809
|
+
return header, events
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _vscode_response_events(response: Any, ts: int | None, events: list[dict[str, Any]]) -> None:
|
|
813
|
+
if not isinstance(response, list):
|
|
814
|
+
return
|
|
815
|
+
for part in response:
|
|
816
|
+
if not isinstance(part, dict):
|
|
817
|
+
continue
|
|
818
|
+
kind = part.get("kind")
|
|
819
|
+
if kind is None: # markdown text block: {"value": "...", ...}
|
|
820
|
+
value = part.get("value")
|
|
821
|
+
text = value.get("value") if isinstance(value, dict) else value
|
|
822
|
+
if isinstance(text, str) and text.strip():
|
|
823
|
+
events.append({"kind": "text", "text": text, "ts": ts})
|
|
824
|
+
elif kind == "thinking":
|
|
825
|
+
value = part.get("value")
|
|
826
|
+
if isinstance(value, str) and value.strip():
|
|
827
|
+
events.append({"kind": "thinking", "text": value, "ts": ts})
|
|
828
|
+
elif kind == "toolInvocationSerialized":
|
|
829
|
+
msg = part.get("pastTenseMessage") or part.get("invocationMessage")
|
|
830
|
+
label = msg.get("value") if isinstance(msg, dict) else msg
|
|
831
|
+
events.append(
|
|
832
|
+
{
|
|
833
|
+
"kind": "tool_use",
|
|
834
|
+
"tool": part.get("toolId"),
|
|
835
|
+
"tool_use_id": part.get("toolCallId"),
|
|
836
|
+
"input": label if isinstance(label, str) else None,
|
|
837
|
+
"ts": ts,
|
|
838
|
+
}
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _vscode_chat_path(session_id: str) -> Path | None:
|
|
843
|
+
if not _safe_id(session_id) or session_id in (".", ".."):
|
|
844
|
+
return None
|
|
845
|
+
matches: list[Path] = []
|
|
846
|
+
for root in VSCODE_CHAT_ROOTS:
|
|
847
|
+
if root.exists():
|
|
848
|
+
matches.extend(root.glob(f"*/chatSessions/{session_id}.jsonl"))
|
|
849
|
+
matches = [p for p in matches if p.is_file()]
|
|
850
|
+
return max(matches, key=_safe_mtime) if matches else None
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _vscode_cwd(path: Path) -> str | None:
|
|
854
|
+
# chatSessions/<id>.jsonl -> workspace.json sits two levels up (workspace hash dir)
|
|
855
|
+
meta = path.parent.parent / "workspace.json"
|
|
856
|
+
try:
|
|
857
|
+
data = json.loads(meta.read_text(encoding="utf-8"))
|
|
858
|
+
except (OSError, json.JSONDecodeError, ValueError):
|
|
859
|
+
return None
|
|
860
|
+
folder = data.get("folder") if isinstance(data, dict) else None
|
|
861
|
+
if isinstance(folder, str) and folder.startswith("file://"):
|
|
862
|
+
path = unquote(urlparse(folder).path)
|
|
863
|
+
# Windows file URLs parse to "/C:/..." — drop the spurious leading slash.
|
|
864
|
+
if re.match(r"^/[A-Za-z]:/", path):
|
|
865
|
+
path = path[1:]
|
|
866
|
+
return path or None
|
|
867
|
+
return folder if isinstance(folder, str) and folder else None
|
|
868
|
+
|
|
869
|
+
|
|
628
870
|
# --- shared helpers --------------------------------------------------------
|
|
629
871
|
def _iter_jsonl(path: Path) -> Iterator[dict[str, Any]]:
|
|
630
872
|
try:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "coding-bridge"
|
|
7
|
-
version = "2026.7.2.
|
|
7
|
+
version = "2026.7.2.2"
|
|
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"
|
|
@@ -139,6 +139,7 @@ def _patch_roots(monkeypatch, tmp_path):
|
|
|
139
139
|
monkeypatch.setattr(history, "CODEX_ROOT", codex_root)
|
|
140
140
|
monkeypatch.setattr(history, "CODEX_INDEX", codex_index)
|
|
141
141
|
monkeypatch.setattr(history, "COPILOT_ROOT", copilot_root)
|
|
142
|
+
monkeypatch.setattr(history, "VSCODE_CHAT_ROOTS", [tmp_path / "vscode"])
|
|
142
143
|
|
|
143
144
|
|
|
144
145
|
def _seed_copilot(root):
|
|
@@ -374,6 +375,7 @@ def test_copilot_falls_back_to_transformed_content(monkeypatch, tmp_path):
|
|
|
374
375
|
monkeypatch.setattr(history, "CODEX_ROOT", tmp_path / "none-codex")
|
|
375
376
|
monkeypatch.setattr(history, "CODEX_INDEX", tmp_path / "none.jsonl")
|
|
376
377
|
monkeypatch.setattr(history, "COPILOT_ROOT", copilot_root)
|
|
378
|
+
monkeypatch.setattr(history, "VSCODE_CHAT_ROOTS", [tmp_path / "none-vscode"])
|
|
377
379
|
copilot = next(s for s in history.list_sessions() if s["provider"] == "copilot")
|
|
378
380
|
assert copilot["title"] == "show me recent sessions"
|
|
379
381
|
detail = history.read_session("copilot", COPILOT_SID)
|
|
@@ -599,3 +601,166 @@ async def test_dispatch_history_get_requires_params():
|
|
|
599
601
|
await conn._dispatch({"action": Action.HISTORY_GET})
|
|
600
602
|
errors = _payloads(conn, Event.SESSION_ERROR)
|
|
601
603
|
assert errors and "required" in errors[0]["message"]
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
VSCODE_SID = "55555555-5555-5555-5555-555555555555"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _seed_vscode_chat(storage_root):
|
|
610
|
+
ws = storage_root / "abc123hash"
|
|
611
|
+
(ws / "chatSessions").mkdir(parents=True, exist_ok=True)
|
|
612
|
+
(ws / "workspace.json").write_text(
|
|
613
|
+
json.dumps({"folder": "file:///Users/me/My%20Proj"}), encoding="utf-8"
|
|
614
|
+
)
|
|
615
|
+
# v3 delta log: header line then a kind:2 line carrying the request objects.
|
|
616
|
+
records = [
|
|
617
|
+
{"kind": 0, "v": {"version": 3, "sessionId": VSCODE_SID, "requests": []}},
|
|
618
|
+
{
|
|
619
|
+
"kind": 2,
|
|
620
|
+
"v": [
|
|
621
|
+
{
|
|
622
|
+
"requestId": "req_1",
|
|
623
|
+
"timestamp": 1750000000000,
|
|
624
|
+
"modelId": "copilot/claude-opus-4.8",
|
|
625
|
+
"message": {"text": "fix the parser"},
|
|
626
|
+
"response": [
|
|
627
|
+
{"kind": "thinking", "value": "let me look", "id": "t0"},
|
|
628
|
+
{"value": "Here is the fix.", "supportThemeIcons": False},
|
|
629
|
+
{
|
|
630
|
+
"kind": "toolInvocationSerialized",
|
|
631
|
+
"toolId": "copilot_readFile",
|
|
632
|
+
"toolCallId": "call_1",
|
|
633
|
+
"invocationMessage": {"value": "Reading file"},
|
|
634
|
+
"isComplete": True,
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
}
|
|
638
|
+
],
|
|
639
|
+
},
|
|
640
|
+
]
|
|
641
|
+
_write_jsonl(ws / "chatSessions" / f"{VSCODE_SID}.jsonl", records)
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def test_vscode_chat_list_and_read(monkeypatch, tmp_path):
|
|
645
|
+
storage = tmp_path / "workspaceStorage"
|
|
646
|
+
_seed_vscode_chat(storage)
|
|
647
|
+
monkeypatch.setattr(history, "VSCODE_CHAT_ROOTS", [storage])
|
|
648
|
+
monkeypatch.setattr(history, "CLAUDE_ROOT", tmp_path / "none1")
|
|
649
|
+
monkeypatch.setattr(history, "CODEX_ROOT", tmp_path / "none2")
|
|
650
|
+
monkeypatch.setattr(history, "COPILOT_ROOT", tmp_path / "none3")
|
|
651
|
+
|
|
652
|
+
sessions = history.list_sessions()
|
|
653
|
+
vs = [s for s in sessions if s["session_id"] == VSCODE_SID]
|
|
654
|
+
assert len(vs) == 1
|
|
655
|
+
s = vs[0]
|
|
656
|
+
assert s["provider"] == "copilot" # surfaced as ordinary Copilot, no "vscode"
|
|
657
|
+
assert "origin" not in s
|
|
658
|
+
assert s["title"] == "fix the parser"
|
|
659
|
+
assert s["cwd"] == "/Users/me/My Proj" # file:// unquoted
|
|
660
|
+
|
|
661
|
+
detail = history.read_session("copilot", VSCODE_SID)
|
|
662
|
+
assert "origin" not in detail
|
|
663
|
+
assert detail["model"] == "copilot/claude-opus-4.8"
|
|
664
|
+
kinds = [e["kind"] for e in detail["events"]]
|
|
665
|
+
assert kinds == ["prompt", "thinking", "text", "tool_use"]
|
|
666
|
+
assert detail["events"][0]["text"] == "fix the parser"
|
|
667
|
+
assert detail["events"][2]["text"] == "Here is the fix."
|
|
668
|
+
assert detail["events"][3]["tool"] == "copilot_readFile"
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
def test_vscode_chat_path_rejects_traversal(monkeypatch, tmp_path):
|
|
672
|
+
storage = tmp_path / "workspaceStorage"
|
|
673
|
+
storage.mkdir(parents=True, exist_ok=True)
|
|
674
|
+
monkeypatch.setattr(history, "VSCODE_CHAT_ROOTS", [storage])
|
|
675
|
+
monkeypatch.setattr(history, "COPILOT_ROOT", tmp_path / "none")
|
|
676
|
+
assert history._vscode_chat_path("..") is None
|
|
677
|
+
assert history._vscode_chat_path(".") is None
|
|
678
|
+
with pytest.raises(FileNotFoundError):
|
|
679
|
+
history.read_session("copilot", "..")
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def test_vscode_chat_tolerates_malformed(monkeypatch, tmp_path):
|
|
683
|
+
storage = tmp_path / "workspaceStorage"
|
|
684
|
+
ws = storage / "h" / "chatSessions"
|
|
685
|
+
ws.mkdir(parents=True, exist_ok=True)
|
|
686
|
+
(ws / f"{VSCODE_SID}.jsonl").write_text(
|
|
687
|
+
"\n".join(
|
|
688
|
+
[
|
|
689
|
+
"not json",
|
|
690
|
+
json.dumps({"kind": 1, "v": "just a string"}),
|
|
691
|
+
json.dumps({"kind": 2, "v": [{"message": ["bad"], "response": []}]}),
|
|
692
|
+
json.dumps(
|
|
693
|
+
{
|
|
694
|
+
"kind": 2,
|
|
695
|
+
"v": [{"requestId": "r", "message": {"text": "real"}, "response": []}],
|
|
696
|
+
}
|
|
697
|
+
),
|
|
698
|
+
]
|
|
699
|
+
)
|
|
700
|
+
+ "\n",
|
|
701
|
+
encoding="utf-8",
|
|
702
|
+
)
|
|
703
|
+
monkeypatch.setattr(history, "VSCODE_CHAT_ROOTS", [storage])
|
|
704
|
+
monkeypatch.setattr(history, "COPILOT_ROOT", tmp_path / "none")
|
|
705
|
+
detail = history.read_session("copilot", VSCODE_SID)
|
|
706
|
+
prompts = [e["text"] for e in detail["events"] if e["kind"] == "prompt"]
|
|
707
|
+
assert prompts == ["real"]
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def test_copilot_native_and_build_seed(monkeypatch, tmp_path):
|
|
711
|
+
# A VS Code chat session is NOT natively resumable but seeds fine.
|
|
712
|
+
storage = tmp_path / "workspaceStorage"
|
|
713
|
+
_seed_vscode_chat(storage)
|
|
714
|
+
monkeypatch.setattr(history, "VSCODE_CHAT_ROOTS", [storage])
|
|
715
|
+
monkeypatch.setattr(history, "COPILOT_ROOT", tmp_path / "none")
|
|
716
|
+
|
|
717
|
+
assert history.copilot_native(VSCODE_SID) is False
|
|
718
|
+
seed = history.build_seed(VSCODE_SID)
|
|
719
|
+
assert "User: fix the parser" in seed
|
|
720
|
+
assert "Assistant: Here is the fix." in seed
|
|
721
|
+
assert seed.startswith("[Continuing a previous conversation")
|
|
722
|
+
assert seed.rstrip().endswith("continue from here.]")
|
|
723
|
+
# tool_use / thinking are omitted from the seed
|
|
724
|
+
assert "copilot_readFile" not in seed
|
|
725
|
+
assert "let me look" not in seed
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def test_build_seed_missing_returns_empty(monkeypatch, tmp_path):
|
|
729
|
+
monkeypatch.setattr(history, "VSCODE_CHAT_ROOTS", [tmp_path / "none-vs"])
|
|
730
|
+
monkeypatch.setattr(history, "COPILOT_ROOT", tmp_path / "none-cli")
|
|
731
|
+
assert history.build_seed("00000000-0000-0000-0000-000000000000") == ""
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
def test_build_seed_tail_caps(monkeypatch, tmp_path):
|
|
735
|
+
storage = tmp_path / "workspaceStorage"
|
|
736
|
+
ws = storage / "h" / "chatSessions"
|
|
737
|
+
ws.mkdir(parents=True, exist_ok=True)
|
|
738
|
+
big = "x" * 60000
|
|
739
|
+
_write_jsonl(
|
|
740
|
+
ws / f"{VSCODE_SID}.jsonl",
|
|
741
|
+
[
|
|
742
|
+
{
|
|
743
|
+
"kind": 2,
|
|
744
|
+
"v": [
|
|
745
|
+
{
|
|
746
|
+
"requestId": "r1",
|
|
747
|
+
"timestamp": 1,
|
|
748
|
+
"message": {"text": "early turn " + big},
|
|
749
|
+
"response": [{"value": "ok"}],
|
|
750
|
+
},
|
|
751
|
+
{
|
|
752
|
+
"requestId": "r2",
|
|
753
|
+
"timestamp": 2,
|
|
754
|
+
"message": {"text": "latest question"},
|
|
755
|
+
"response": [{"value": "latest answer"}],
|
|
756
|
+
},
|
|
757
|
+
],
|
|
758
|
+
}
|
|
759
|
+
],
|
|
760
|
+
)
|
|
761
|
+
monkeypatch.setattr(history, "VSCODE_CHAT_ROOTS", [storage])
|
|
762
|
+
monkeypatch.setattr(history, "COPILOT_ROOT", tmp_path / "none")
|
|
763
|
+
seed = history.build_seed(VSCODE_SID)
|
|
764
|
+
assert len(seed) < 45000 # capped
|
|
765
|
+
assert "latest question" in seed # tail kept
|
|
766
|
+
assert "omitted" in seed
|
|
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.0 → coding_bridge-2026.7.2.2}/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
|