From b9f8eca10cad0e8ecc4936a31d7d63064918cdc8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 24 Jul 2026 11:23:16 +0800 Subject: [PATCH 01/15] fix(python-sdk): track recursive subagent notifications --- ...python-sdk-session-notifications.i18n.yaml | 6 ++ ...ursive-python-sdk-session-notifications.md | 27 ++++++ ...ive-python-sdk-session-notifications.zh.md | 27 ++++++ python/sdk/README.i18n.yaml | 4 +- python/sdk/README.md | 4 +- python/sdk/README.zh.md | 2 +- python/sdk/src/deepseek_harness/api.py | 5 +- python/sdk/src/deepseek_harness/client.py | 64 +++++++++++--- python/sdk/tests/test_client.py | 86 +++++++++++++++++++ 9 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml new file mode 100644 index 0000000000..0e8626c838 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-recursive-python-sdk-session-notifications.md: c608cfa584e568602d599c7e18ffc36fabbd0186 +2026-07-24-recursive-python-sdk-session-notifications.zh.md: 397669cc13a5c77dabce55209be3cd2f0c7a82ea diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md new file mode 100644 index 0000000000..c608cfa584 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md @@ -0,0 +1,27 @@ +# Agent Note: Recursive Python SDK session notifications + +Status: implemented + +English | [中文](2026-07-24-recursive-python-sdk-session-notifications.zh.md) + +## Problem + +The Python SDK filtered turn notifications by comparing each payload directly with the root session id. This admitted a direct child's lifecycle because its parent id named the root, but rejected a grandchild's lifecycle and every descendant `session.event`. The JSON-RPC server still emitted those notifications, so they accumulated on the low-level global queue while high-level consumers lost nested trajectory relationships and completion states. + +## Decision + +`HarnessClient` records every valid `subagent.started` and `subagent.finished` child-to-parent edge before dispatching the notification. Session subscriptions classify each payload session id, parent id, and child id by walking that client-lifetime ancestry graph to their requested root. The graph survives successive subscriptions so a descendant that outlives one `Session.run()` remains attributable when it emits during a later turn, and it resets when the client starts a new runtime process. + +`Session.run()` delivers the complete discovered session-tree notification stream through `TurnResult.notifications` and `on_notification`. Only `session.event` notifications whose `sessionId` equals the requested root enter `TurnResult.events` or final-response reconstruction. Descendant events are therefore observable without allowing a child response to replace the root response. + +## Alternatives considered + +**Add a root session id to every JSON-RPC notification.** The server already provides exact immediate-parent edges, and duplicating transitive ancestry on the wire would make every producer responsible for client subscription state. + +**Limit subagents to one level.** A deployment can set `maxDepth: 1`, but changing the SDK to depend on that policy would silently misreport valid recursive compositions. + +**Subscribe only to descendant lifecycle notifications.** This would repair relation and completion reporting, but descendant session events would continue accumulating on the global queue and callbacks would expose an incomplete tree. + +## Consequences + +High-level consumers receive nested lifecycle and session notifications in wire order while root turn results preserve their prior response semantics. The client retains one parent entry per observed child until the runtime restarts; ancestry lookup is cycle-safe, and unrelated session notifications remain available through the global queue. Keyless Python tests cover two-level delegation, root-response isolation, absence of tree-notification queue buildup, and ancestry reuse across subscriptions. diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md new file mode 100644 index 0000000000..397669cc13 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Python SDK 递归会话通知 + +Status: implemented + +[English](2026-07-24-recursive-python-sdk-session-notifications.md) | 中文 + +## 问题 + +Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较来过滤轮次通知。直接子 agent 的生命周期通知因 parent ID 指向根会话而能够通过,但孙级生命周期通知与所有后代 `session.event` 都会被拒绝。JSON-RPC 服务器仍会发出这些通知,因此它们会堆积在底层全局队列中,而高层消费者会丢失嵌套轨迹的关系与结束状态。 + +## 决策 + +`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 和 `subagent.finished` 所包含的 child-to-parent(子到父)关系。会话订阅会沿客户端生命周期内保存的祖先关系图回溯每个 payload 中的 session ID、parent ID 与 child ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使跨过一次 `Session.run()`,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。 + +`Session.run()` 通过 `TurnResult.notifications` 与 `on_notification` 提供已发现会话树的完整通知流。只有 `sessionId` 等于请求根会话的 `session.event` 才会进入 `TurnResult.events` 或参与最终回复重建。因此调用方能够观察后代事件,同时子会话回复不会覆盖根会话回复。 + +## 考虑过的替代方案 + +**在每条 JSON-RPC 通知中加入根会话 ID。** 服务器已经提供精确的直接父子关系;在线路协议中重复传递祖先关系,会迫使每个生产者承担客户端订阅状态的职责。 + +**把 subagent 限制为一层。** 部署可以设置 `maxDepth: 1`,但让 SDK 依赖该策略,会对合法的递归组合产生静默误报。 + +**只订阅后代生命周期通知。** 这可以修复关系与结束状态的上报,但后代会话事件仍会堆积在全局队列中,回调看到的会话树也不完整。 + +## 后果 + +高层消费者会按线上的原始顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层派生、根回复隔离、会话树通知不堆积,以及跨订阅复用祖先关系。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 956d6f8ff8..ed74d60087 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 23d15d617b3d295a6cc2d8d20c6d03abc226834b -README.zh.md: 4f6aef13833af937babc2e5a92bfd14c12170534 +README.md: bfa31a712acd6fccf1458a0a80fc2ff80dfe114e +README.zh.md: 11ebcdd133b2fd839b73f50ef2be2e531e8bbc2a diff --git a/python/sdk/README.md b/python/sdk/README.md index 23d15d617b..bfa31a712a 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -34,9 +34,7 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. The bundled default composition registers `deepseek`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. -`TurnResult.final_response` is the text content from the last -`assistant/message` event in the turn. Use `TurnResult.events` for the complete -event stream, including intermediate assistant messages and tool activity. +`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `TurnResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `TurnResult.events` remains the root session's complete event stream, and `TurnResult.final_response` is the text content from its last `assistant/message`; descendant messages therefore cannot replace the root response. The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin`, `bridge_bin`, or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 4f6aef1383..11ebcdd133 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -30,7 +30,7 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。内置默认组合注册 `deepseek`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 -`TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。 +`HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`TurnResult.notifications` 与 `on_notification` 会按线上的原始顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期与会话事件。`TurnResult.events` 仍只保存根会话的完整事件流,`TurnResult.final_response` 则取该会话最后一个 `assistant/message` 的文本内容,因此后代消息不会覆盖根会话回复。 同样的行为也可以通过 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此底层客户端的默认启动也具有此行为:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值视为缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin`、`bridge_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 `node` 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index 2b44a50c64..d96e974bc3 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -143,7 +143,10 @@ class Session: notifications.append(notification) if on_notification is not None: on_notification(notification) - if notification.method == "session.event": + if ( + notification.method == "session.event" + and notification.payload.get("sessionId") == self.id + ): event = notification.payload.get("event") if isinstance(event, dict): events.append(event) diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index e552c8b685..b80d83b5f4 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -47,6 +47,7 @@ class HarnessClient: self._notification_subscribers: dict[ str, tuple[queue.Queue[Notification | BaseException], NotificationFilter | None] ] = {} + self._session_parents: dict[str, str] = {} self._requests: queue.Queue[IncomingRequest | BaseException] = queue.Queue() self._stderr_lines: deque[str] = deque(maxlen=400) self._reader_thread: threading.Thread | None = None @@ -62,6 +63,8 @@ class HarnessClient: def start(self) -> None: if self._proc is not None: return + with self._lock: + self._session_parents.clear() args = list(self.config.launch_args_override or self._default_launch_args()) env = os.environ.copy() if self.config.env: @@ -143,7 +146,7 @@ class HarnessClient: payload, response_model=_SessionPromptResponse, on_notification=on_notification, - notification_filter=_notification_belongs_to_session(session_id), + notification_filter=self._notification_belongs_to_session_tree(session_id), notification_subscription=notification_subscription, ) @@ -193,7 +196,8 @@ class HarnessClient: return NotificationSubscription(self, subscription_id, notifications) def subscribe_session_notifications(self, session_id: str) -> "NotificationSubscription": - return self.subscribe_notifications(_notification_belongs_to_session(session_id)) + """Subscribe to a session and descendants discovered from subagent lifecycle edges.""" + return self.subscribe_notifications(self._notification_belongs_to_session_tree(session_id)) def next_request(self) -> IncomingRequest: item = self._requests.get() @@ -352,6 +356,7 @@ class HarnessClient: params = message.get("params") notification = Notification(method=method, payload=params if isinstance(params, dict) else {}) with self._lock: + self._record_session_relationship_locked(notification) subscribers = list(self._notification_subscribers.items()) delivered = False for subscription_id, (subscriber, predicate) in subscribers: @@ -439,6 +444,49 @@ class HarnessClient: with self._lock: self._notification_subscribers.pop(subscription_id, None) + def _record_session_relationship_locked(self, notification: Notification) -> None: + if notification.method not in {"subagent.started", "subagent.finished"}: + return + parent_id = notification.payload.get("parentSessionId") + child_id = notification.payload.get("childSessionId") + if ( + isinstance(parent_id, str) + and parent_id + and isinstance(child_id, str) + and child_id + and parent_id != child_id + ): + self._session_parents[child_id] = parent_id + + def _notification_belongs_to_session_tree(self, session_id: str) -> NotificationFilter: + def belongs(notification: Notification) -> bool: + payload = notification.payload + related_ids = ( + payload.get("sessionId"), + payload.get("parentSessionId"), + payload.get("childSessionId"), + ) + return any( + isinstance(related_id, str) + and self._session_is_descendant_of(related_id, session_id) + for related_id in related_ids + ) + + return belongs + + def _session_is_descendant_of(self, session_id: str, root_session_id: str) -> bool: + current = session_id + visited: set[str] = set() + while current not in visited: + if current == root_session_id: + return True + visited.add(current) + parent = self._session_parents.get(current) + if parent is None: + return False + current = parent + return False + class NotificationSubscription: def __init__( @@ -491,15 +539,3 @@ class _ShutdownResponse(BaseModel): def _int_or_none(value: object) -> int | None: return value if isinstance(value, int) else None - - -def _notification_belongs_to_session(session_id: str) -> NotificationFilter: - def belongs(notification: Notification) -> bool: - payload = notification.payload - return ( - payload.get("sessionId") == session_id - or payload.get("parentSessionId") == session_id - or payload.get("childSessionId") == session_id - ) - - return belongs diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index f66abf4b36..fda0686980 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -198,6 +198,65 @@ for line in sys.stdin: ] +def test_session_run_collects_nested_subagent_tree_without_polluting_root_events( + tmp_path: Path, +) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + root = (msg.get("params") or {})["sessionId"] + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": root, "childSessionId": "child"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "child", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "child response"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "child", "childSessionId": "grandchild"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "grandchild", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "grandchild response"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "child", "childSessionId": "grandchild", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": root, "childSessionId": "child", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": root, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "root response"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": root, "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + seen: list[str] = [] + with DeepSeekHarness( + launch_args_override=(sys.executable, str(script)), + cwd=str(tmp_path), + ) as harness: + result = harness.run( + "delegate recursively", + session_id="main", + on_notification=lambda notification: seen.append(notification.method), + ) + assert harness.client._notifications.qsize() == 0 + + assert result.status == "ok" + assert result.final_response == "root response" + assert [event["data"]["content"][0]["text"] for event in result.events] == ["root response"] + assert [notification.method for notification in result.notifications] == [ + "subagent.started", + "session.event", + "subagent.started", + "session.event", + "subagent.finished", + "subagent.finished", + "session.event", + "session.finished", + ] + assert seen == [notification.method for notification in result.notifications] + + def test_session_run_ignores_notifications_for_other_sessions(tmp_path: Path) -> None: script = tmp_path / "fake_runtime.py" script.write_text( @@ -356,6 +415,33 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe assert notification.payload["sessionId"] == "other" +def test_session_subscription_keeps_descendant_relationships_across_subscriptions() -> None: + client = HarnessClient() + with client.subscribe_session_notifications("main") as first: + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.started", + "params": {"parentSessionId": "main", "childSessionId": "child"}, + }) + assert first.next().payload["childSessionId"] == "child" + + with client.subscribe_session_notifications("main") as second: + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.started", + "params": {"parentSessionId": "child", "childSessionId": "grandchild"}, + }) + client._handle_message({ + "jsonrpc": "2.0", + "method": "session.event", + "params": {"sessionId": "grandchild", "event": {"type": "assistant/message"}}, + }) + assert second.next().payload["childSessionId"] == "grandchild" + assert second.next().payload["sessionId"] == "grandchild" + + assert client._notifications.qsize() == 0 + + def test_client_contains_notification_filter_failure_to_its_subscription(tmp_path: Path) -> None: script = tmp_path / "fake_bridge.py" script.write_text( From 11eedca16e0ecb9e6cc53865965478ac9d74bcfd Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 24 Jul 2026 12:21:10 +0800 Subject: [PATCH 02/15] fix(python-sdk): preserve reused session ancestry --- ...python-sdk-session-notifications.i18n.yaml | 4 +- ...ursive-python-sdk-session-notifications.md | 6 +- ...ive-python-sdk-session-notifications.zh.md | 6 +- python/sdk/src/deepseek_harness/client.py | 19 +++--- python/sdk/tests/test_client.py | 61 ++++++++++++++++++- 5 files changed, 81 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml index 0e8626c838..cae5b75cb4 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-recursive-python-sdk-session-notifications.md: c608cfa584e568602d599c7e18ffc36fabbd0186 -2026-07-24-recursive-python-sdk-session-notifications.zh.md: 397669cc13a5c77dabce55209be3cd2f0c7a82ea +2026-07-24-recursive-python-sdk-session-notifications.md: c90213659391b565acd043a1be64e225f8babd31 +2026-07-24-recursive-python-sdk-session-notifications.zh.md: 214a5ef924dcc9da3a97aab6385837acd2b364d9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md index c608cfa584..c902136593 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.md @@ -10,7 +10,7 @@ The Python SDK filtered turn notifications by comparing each payload directly wi ## Decision -`HarnessClient` records every valid `subagent.started` and `subagent.finished` child-to-parent edge before dispatching the notification. Session subscriptions classify each payload session id, parent id, and child id by walking that client-lifetime ancestry graph to their requested root. The graph survives successive subscriptions so a descendant that outlives one `Session.run()` remains attributable when it emits during a later turn, and it resets when the client starts a new runtime process. +`HarnessClient` records every valid `subagent.started` child-to-parent edge before dispatching the notification. A later `subagent.finished` routes by its immutable parent id but never rewrites current ancestry, so an older run that settles after its child id has been reused cannot displace the replacement session. Other session notifications resolve their session id by walking that client-lifetime ancestry graph to the requested root. The graph survives successive subscriptions so a descendant that outlives one `Session.run()` remains attributable when it emits during a later turn, and it resets when the client starts a new runtime process. `Session.run()` delivers the complete discovered session-tree notification stream through `TurnResult.notifications` and `on_notification`. Only `session.event` notifications whose `sessionId` equals the requested root enter `TurnResult.events` or final-response reconstruction. Descendant events are therefore observable without allowing a child response to replace the root response. @@ -22,6 +22,8 @@ The Python SDK filtered turn notifications by comparing each payload directly wi **Subscribe only to descendant lifecycle notifications.** This would repair relation and completion reporting, but descendant session events would continue accumulating on the global queue and callbacks would expose an incomplete tree. +**Expose and index every subagent run id on the JSON-RPC wire.** Exact run identity is useful when a client must correlate two concurrent outcomes for the same child, but session-tree routing already has the authoritative start edge and each terminal notification's immutable parent. Expanding the protocol is unnecessary for this ownership decision. + ## Consequences -High-level consumers receive nested lifecycle and session notifications in wire order while root turn results preserve their prior response semantics. The client retains one parent entry per observed child until the runtime restarts; ancestry lookup is cycle-safe, and unrelated session notifications remain available through the global queue. Keyless Python tests cover two-level delegation, root-response isolation, absence of tree-notification queue buildup, and ancestry reuse across subscriptions. +High-level consumers receive nested lifecycle and session notifications in wire order while root turn results preserve their prior response semantics. The client retains one current parent entry per observed child until the runtime restarts; ancestry lookup is cycle-safe, and unrelated session notifications remain available through the global queue. Keyless Python tests cover two-level delegation, root-response isolation, absence of tree-notification queue buildup, ancestry reuse across subscriptions, and reused child ids whose older runs settle out of order. diff --git a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md index 397669cc13..214a5ef924 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-24-recursive-python-sdk-session-notifications.zh.md @@ -10,7 +10,7 @@ Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较 ## 决策 -`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 和 `subagent.finished` 所包含的 child-to-parent(子到父)关系。会话订阅会沿客户端生命周期内保存的祖先关系图回溯每个 payload 中的 session ID、parent ID 与 child ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使跨过一次 `Session.run()`,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。 +`HarnessClient` 会在分发通知前,记录每条有效 `subagent.started` 所包含的 child-to-parent(子到父)关系。后续的 `subagent.finished` 会依据自身不可变的 parent ID 路由,但不会改写当前祖先关系,因此旧 run 即使在其 child ID 已被复用后才结束,也无法覆盖替代它的新会话。其他会话通知会沿客户端生命周期内保存的祖先关系图回溯自身 session ID,判断它们是否属于请求的根会话。该关系图会跨连续订阅保留,因此某个后代即使跨过一次 `Session.run()`,在后续轮次中发出通知时仍能正确归属;客户端启动新的运行时进程时会重置关系图。 `Session.run()` 通过 `TurnResult.notifications` 与 `on_notification` 提供已发现会话树的完整通知流。只有 `sessionId` 等于请求根会话的 `session.event` 才会进入 `TurnResult.events` 或参与最终回复重建。因此调用方能够观察后代事件,同时子会话回复不会覆盖根会话回复。 @@ -22,6 +22,8 @@ Python SDK 过去通过将每条通知的 payload 与根会话 ID 直接比较 **只订阅后代生命周期通知。** 这可以修复关系与结束状态的上报,但后代会话事件仍会堆积在全局队列中,回调看到的会话树也不完整。 +**在 JSON-RPC 线路上公开并索引每个 subagent run ID。** 当客户端必须关联同一 child 的两个并发结果时,精确 run 身份很有价值;但会话树路由已经拥有权威 start 关系和每条终止通知中不可变的 parent。没有必要为这一归属决策扩展协议。 + ## 后果 -高层消费者会按线上的原始顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层派生、根回复隔离、会话树通知不堆积,以及跨订阅复用祖先关系。 +高层消费者会按线上的原始顺序收到嵌套生命周期与会话通知,同时根轮次结果保持原有回复语义。客户端会为每个已观察到的子会话保留一条当前父关系,直到运行时重启;祖先回溯能够安全处理环,无关会话通知仍可从全局队列获取。无密钥 Python 测试覆盖两层派生、根回复隔离、会话树通知不堆积、跨订阅复用祖先关系,以及旧 run 乱序结束的复用 child ID。 diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index b80d83b5f4..8d4ec7f848 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -445,7 +445,7 @@ class HarnessClient: self._notification_subscribers.pop(subscription_id, None) def _record_session_relationship_locked(self, notification: Notification) -> None: - if notification.method not in {"subagent.started", "subagent.finished"}: + if notification.method != "subagent.started": return parent_id = notification.payload.get("parentSessionId") child_id = notification.payload.get("childSessionId") @@ -461,15 +461,18 @@ class HarnessClient: def _notification_belongs_to_session_tree(self, session_id: str) -> NotificationFilter: def belongs(notification: Notification) -> bool: payload = notification.payload - related_ids = ( - payload.get("sessionId"), - payload.get("parentSessionId"), - payload.get("childSessionId"), - ) - return any( + if notification.method in {"subagent.started", "subagent.finished"}: + parent_id = payload.get("parentSessionId") + if ( + isinstance(parent_id, str) + and self._session_is_descendant_of(parent_id, session_id) + ): + return True + return payload.get("childSessionId") == session_id + related_id = payload.get("sessionId") + return ( isinstance(related_id, str) and self._session_is_descendant_of(related_id, session_id) - for related_id in related_ids ) return belongs diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index fda0686980..d5460b8683 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -9,7 +9,7 @@ from pathlib import Path import pytest -from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig +from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, Notification def test_high_level_sdk_runs_turn_and_collects_final_response(tmp_path: Path) -> None: @@ -442,6 +442,65 @@ def test_session_subscription_keeps_descendant_relationships_across_subscription assert client._notifications.qsize() == 0 +def test_session_subscription_preserves_reused_child_ancestry_after_late_finish() -> None: + client = HarnessClient() + old_seen: list[Notification] = [] + new_seen: list[Notification] = [] + with ( + client.subscribe_session_notifications("old-parent") as old_subscription, + client.subscribe_session_notifications("new-parent") as new_subscription, + ): + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.started", + "params": {"parentSessionId": "old-parent", "childSessionId": "reused-child"}, + }) + old_subscription.drain(old_seen.append) + new_subscription.drain(new_seen.append) + assert [notification.method for notification in old_seen] == ["subagent.started"] + assert new_seen == [] + + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.started", + "params": {"parentSessionId": "new-parent", "childSessionId": "reused-child"}, + }) + old_subscription.drain(old_seen.append) + new_subscription.drain(new_seen.append) + assert [notification.method for notification in new_seen] == ["subagent.started"] + + client._handle_message({ + "jsonrpc": "2.0", + "method": "subagent.finished", + "params": {"parentSessionId": "old-parent", "childSessionId": "reused-child"}, + }) + old_subscription.drain(old_seen.append) + new_subscription.drain(new_seen.append) + assert [notification.method for notification in old_seen] == [ + "subagent.started", + "subagent.finished", + ] + assert [notification.method for notification in new_seen] == ["subagent.started"] + + client._handle_message({ + "jsonrpc": "2.0", + "method": "session.event", + "params": {"sessionId": "reused-child", "event": {"type": "assistant/message"}}, + }) + old_subscription.drain(old_seen.append) + new_subscription.drain(new_seen.append) + + assert [notification.method for notification in old_seen] == [ + "subagent.started", + "subagent.finished", + ] + assert [notification.method for notification in new_seen] == [ + "subagent.started", + "session.event", + ] + assert client._notifications.qsize() == 0 + + def test_client_contains_notification_filter_failure_to_its_subscription(tmp_path: Path) -> None: script = tmp_path / "fake_bridge.py" script.write_text( From f97f9bcf8a71fdc6c81cc6c3fc72a5042c6ce392 Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 24 Jul 2026 13:23:10 +0800 Subject: [PATCH 03/15] chore(examples): declare @deepseek-ai/dsh-llm-pi-ai as an example dep pi-ai is the library-backed twin adapter the tui-agent README already points at ("swap one line to @deepseek-ai/dsh-llm-pi-ai"), and the supported entry point for third-party providers (Anthropic, Google, OpenRouter) mounted through the personal overlay under ~/.dsh. Making it a declared workspace dep of the examples umbrella means `pnpm install` resolves the symlink upstream so users configuring a third-party provider via `~/.dsh/config.yaml` don't have to patch `examples/package.json` locally (which their next git checkout would wipe). Placement matches the sibling llm-* cluster; workspace:* to match the other adapters. No cordis.yml or README changes: mounting pi-ai remains explicit and opt-in per the provider-routed-llm-adapters Agent Note. --- examples/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/examples/package.json b/examples/package.json index 1b4e28c4fd..bd87263340 100644 --- a/examples/package.json +++ b/examples/package.json @@ -26,6 +26,7 @@ "@deepseek-ai/dsh-jsonrpc": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", "@deepseek-ai/dsh-llm-replay": "workspace:*", "@deepseek-ai/dsh-lsp": "workspace:*", "@deepseek-ai/dsh-lsp-local": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e0d5cd798..aff769a93e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -267,6 +267,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:* version: link:../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-pi-ai': + specifier: workspace:* + version: link:../packages/llm/llm-pi-ai '@deepseek-ai/dsh-llm-replay': specifier: workspace:* version: link:../packages/support/llm-replay From 3babb2cd423de28d409e545d8ef4822d5abb3881 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 13:30:19 +0800 Subject: [PATCH 04/15] feat(trajectory): implement trajectory step cell and layout with bilingual support - Added TrajectoryCell, TrajectoryGroupHeader, and TrajectoryTurn components for rendering trajectory steps and groups. - Introduced bilingual support with English and Chinese translations for trajectory notes. - Updated conversation session models to include timestamps for various message types. - Enhanced layout logic to handle expanded assistant blocks and tool results with duration metrics. - Added CSS styles for new components to ensure proper display and alignment. --- .../2026-07-23-trajectory-step-cell.i18n.yaml | 6 + .../2026-07-23-trajectory-step-cell.md | 35 ++ .../2026-07-23-trajectory-step-cell.zh.md | 35 ++ .../src/client/sessions/conversation.ts | 16 + .../src/client/sessions/fold-adapter.ts | 28 +- .../runtime/src/client/sessions/session.ts | 9 +- .../tests/chat-stats-bash-sample.spec.tsx | 7 +- .../tests/chat-tool-row.spec.tsx | 5 +- .../ui-conversation/tests/chat-view.spec.tsx | 9 +- .../tests/coverage-tails.spec.tsx | 6 +- .../tests/skeleton-branches.spec.tsx | 4 +- .../ui-conversation/tests/skeleton.spec.tsx | 9 +- .../ui-theme/src/styles/design-platform.css | 5 +- packages/client/ui-trajectory/README.md | 4 +- .../src/client/TrajectoryCell.module.css | 93 +++++ .../src/client/TrajectoryCell.tsx | 99 +++++ .../client/TrajectoryGroupHeader.module.css | 27 ++ .../src/client/TrajectoryGroupHeader.tsx | 26 ++ .../src/client/TrajectoryTurn.module.css | 16 + .../src/client/TrajectoryTurn.tsx | 26 ++ .../client/TrajectoryTurnHeader.module.css | 48 +++ .../src/client/TrajectoryTurnHeader.tsx | 30 ++ .../src/client/TrajectoryView.tsx | 50 ++- .../client/ui-trajectory/src/client/index.ts | 4 +- .../client/ui-trajectory/src/client/layout.ts | 374 ++++++++++++++++++ .../ui-trajectory/src/client/views.module.css | 18 +- .../client/ui-trajectory/tests/cell.spec.tsx | 87 ++++ .../ui-trajectory/tests/layout.spec.tsx | 143 +++++++ .../client/ui-trajectory/tests/views.spec.tsx | 41 +- 29 files changed, 1190 insertions(+), 70 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md create mode 100644 .agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryCell.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryCell.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css create mode 100644 packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx create mode 100644 packages/client/ui-trajectory/src/client/layout.ts create mode 100644 packages/client/ui-trajectory/tests/cell.spec.tsx create mode 100644 packages/client/ui-trajectory/tests/layout.spec.tsx diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml new file mode 100644 index 0000000000..fb39ae1301 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-23-trajectory-step-cell.md: edf31dcc72baa980caf0aa90fb8d5ec53d44346d +2026-07-23-trajectory-step-cell.zh.md: dbe813d48c3f3ac1c0926e45137624d758109c55 diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md new file mode 100644 index 0000000000..42d896b81a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md @@ -0,0 +1,35 @@ +# Agent Note: Trajectory step cell and turn list chrome + +Status: implemented + +English | [中文](2026-07-23-trajectory-step-cell.zh.md) + +## Problem + +The trajectory tab needs a reusable step row and turn-list chrome that can show expanded assistant blocks, own-duration times, Message token columns, and in-flight work. Without folding session event times into conversation nodes and expanding blocks into cells, the UI cannot match the product chrome. + +## Decision + +[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) owns the presentational trajectory list chrome: + +- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 38px step row with kinds User / Message / Tool (no Think, Call, or Result rows). Reasoning blocks are skipped (no block-level clock). Each `tool-call` + paired `tool-result` folds into one Tool row (`name ·` truncated args) whose Time is `result.time − callTime` when both are known. Message rows carry Input/Output/Think token columns from `assistant.usage`. Own-duration Time uses `+Ns` / `+N.1s`, or `—` when absent. Selected state draws a 2px inset `--dsw-alias-brand-primary-new-colorprimary-new-color` ring (`selected` prop) and is not wired to chat selection. +- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — sticky Turn bar paints full-bleed `ghost-active-fill`; title/columns and the Message/Step body sit in a centered `max-width: 880px` lane. Cell trailing columns share the Turn header geometry (`320 = 4×71 + 3×12`); cells use pad 20/8. +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only, and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). + +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time; Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration). + +## Alternatives considered + +**Keep a Think cell for reasoning blocks.** Rejected: a single `assistant/message.time` cannot yield Think own-duration without chunk-level clocks; omit the row rather than show `—`. + +**Keep separate Call and Result rows.** Rejected: Result had no own duration to show; one Tool row carries the call→result interval. + +**Cumulative elapsed from session/turn start.** Rejected; the Time column is each row's own duration. + +**Hang usage on the first expanded row.** Rejected; usage attaches to Message only. + +**Show in-flight tool durations via Date.now().** Deferred; in-flight Time stays `—`. + +## Consequences + +The Trajectory tab can render expanded finalized and in-flight rows with own-duration times once fold emits `time`. Behavior-shaped coverage lives in `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`. Chat selection deep-links and finer block-level clocks remain deferred. diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md new file mode 100644 index 0000000000..c6bcde7deb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md @@ -0,0 +1,35 @@ +# Agent Note: Trajectory 步骤单元格与轮次列表 chrome + +Status: implemented + +[English](2026-07-23-trajectory-step-cell.md) | 中文 + +## Problem + +trajectory 标签页需要可复用的步骤行与轮次列表 chrome,以展示展开后的 assistant 块、自身耗时、Message token 列,以及进行中的工作。若不将会话事件时间折叠进会话节点,并将块展开为单元格,UI 就无法对齐产品 chrome。 + +## Decision + +[`@deepseek-ai/dsh-client-ui-trajectory`](../../../../packages/client/ui-trajectory/README.md) 拥有展示型 trajectory 列表 chrome: + +- [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 高 38px 的步骤行,类型为 User / Message / Tool(无 Think、Call、Result 行)。reasoning 块跳过(无块级时钟)。每对 `tool-call` + `tool-result` 折成一行 Tool(`name ·` 加截断参数),Time 在两端皆知时为 `result.time − callTime`。Message 行携带来自 `assistant.usage` 的 Input/Output/Think token 列。自身耗时 Time 使用 `+Ns` / `+N.1s`,缺失时为 `—`。选中态绘制 2px 内嵌的 `--dsw-alias-brand-primary-new-colorprimary-new-color` 环(`selected` prop),且未接线到 chat 选中。 +- [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — 粘性 Turn 条背景通栏铺 `ghost-active-fill`;标题/列标与 Message/Step 主体落在居中的 `max-width: 880px` 内容道。单元格右侧列与 Turn 标头共用几何(`320 = 4×71 + 3×12`);cell pad 20/8。 +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上,并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。 + +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间;Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。 + +## Alternatives considered + +**为 reasoning 块保留 Think 单元格。** 否决:单条 `assistant/message.time` 无法给出 Think 自身耗时(除非上 chunk 级时钟);与其显示 `—`,不如省略该行。 + +**保留分开的 Call 与 Result 行。** 否决:Result 没有可展示的自身耗时;一行 Tool 承载 call→result 区间。 + +**自会话/轮次起点累计耗时。** 否决;Time 列是每行自身的耗时。 + +**将用量挂在展开后的第一行。** 否决;用量仅附着于 Message。 + +**用 Date.now() 显示进行中工具的耗时。** 延后;进行中的 Time 保持为 `—`。 + +## Consequences + +一旦 fold 发出 `time`,Trajectory 标签页即可渲染带自身耗时的已定稿与进行中展开行。行为导向的覆盖位于 `packages/client/ui-trajectory/tests/{cell,layout,views}.spec.tsx`。chat 选中深链与更细的块级时钟仍延后。 diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 1e06fad70d..08b80f2a26 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -42,6 +42,8 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock { export interface UserMessageNode { kind: 'user' seq: number + /** Unix epoch ms from the source session event. */ + time: number content: readonly ContentBlock[] source: unknown } @@ -50,6 +52,8 @@ export interface UserMessageNode { export interface AssistantMessageNode { kind: 'assistant' seq: number + /** Unix epoch ms from the source session event (or turn/end when frozen from a partial). */ + time: number turn: number step: number blocks: readonly AssistantBlock[] @@ -63,6 +67,8 @@ export interface AssistantMessageNode { export interface SteeringMessageNode { kind: 'steering' seq: number + /** Unix epoch ms from the source session event. */ + time: number turn: number content: readonly ContentBlock[] source: unknown @@ -72,6 +78,8 @@ export interface SteeringMessageNode { export interface ContextMessageNode { kind: 'context' seq: number + /** Unix epoch ms from the source session event. */ + time: number content: readonly ContentBlock[] source: unknown meta?: unknown @@ -81,9 +89,13 @@ export interface ContextMessageNode { export interface ToolResultNode { kind: 'tool-result' seq: number + /** Unix epoch ms from the tool/result session event. */ + time: number callId: string /** Call head backfilled from the in-window tool/call; null when window truncation left the call outside (card head shows callId). */ call: { name: string; argsRaw: string } | null + /** Unix epoch ms of the paired tool/call when the call is still in-window; used for call-row duration. */ + callTime: number | null content: readonly ContentBlock[] isError: boolean error?: { name: string; code: string } @@ -98,6 +110,8 @@ export interface ToolResultNode { export interface UnknownSurfaceNode { kind: 'unknown' seq: number + /** Unix epoch ms from the source session event when known. */ + time: number type: string data: unknown } @@ -118,6 +132,8 @@ export interface RunningToolCall { argsRaw: string turn: number step: number + /** Unix epoch ms when the tool/call event was logged. */ + time: number /** Host-computed render intent riding the tool/call frame; null = generic JSON card. */ callView: ToolCallView | null } diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index ccb48a0161..0342bc3c85 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -18,6 +18,8 @@ export interface CallIndexEntry { argsRaw: string turn: number step: number + /** Unix epoch ms of the tool/call event. */ + time: number /** Wire view riding the tool/call (envelope-level; never inside the event). */ callView: ToolCallView | null } @@ -38,24 +40,34 @@ function materializeNode( ): ConversationNode { switch (event.type) { case 'user/message': - return { kind: 'user', seq: event.seq, content: event.data.content, source: event.data.source } + return { + kind: 'user', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, + } case 'assistant/message': return { - kind: 'assistant', seq: event.seq, turn: event.data.turn, step: event.data.step, + kind: 'assistant', seq: event.seq, time: event.time, + turn: event.data.turn, step: event.data.step, blocks: toAssistantBlocks(event.data.content), usage: event.data.usage, } case 'steering/message': - return { kind: 'steering', seq: event.seq, turn: event.data.turn, content: event.data.content, source: event.data.source } + return { + kind: 'steering', seq: event.seq, time: event.time, turn: event.data.turn, + content: event.data.content, source: event.data.source, + } case 'context/message': return { - kind: 'context', seq: event.seq, content: event.data.content, source: event.data.source, + kind: 'context', seq: event.seq, time: event.time, + content: event.data.content, source: event.data.source, meta: event.data.meta, } case 'tool/result': { const call = callIndex.get(String(event.data.callId)) return { - kind: 'tool-result', seq: event.seq, callId: String(event.data.callId), + kind: 'tool-result', seq: event.seq, time: event.time, + callId: String(event.data.callId), call: call ? { name: call.name, argsRaw: call.argsRaw } : null, + callTime: call?.time ?? null, content: event.data.content, isError: event.data.isError, ...(event.data.error !== undefined ? { error: event.data.error } : {}), meta: event.data.meta, @@ -67,7 +79,10 @@ function materializeNode( surface-eligible types, and each has a case above; reachable only if core adds an eligible type. */ default: - return { kind: 'unknown', seq: event.seq, type: event.type, data: (event as { data?: unknown }).data } + return { + kind: 'unknown', seq: event.seq, time: event.time, + type: event.type, data: (event as { data?: unknown }).data, + } } } @@ -186,6 +201,7 @@ export class FoldAdapter { if (event.type !== 'tool/call') return this.callIdx.set(String(event.data.callId), { name: event.data.name, argsRaw: event.data.arguments, turn: event.data.turn, step: event.data.step, + time: event.time, callView: view?.for === 'call' ? view.view : null, }) // No backfill into already-materialized tool-result nodes for this callId diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index c394141d85..6b773e0903 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -434,7 +434,7 @@ export class Session implements ObservableSnapshot { case 'tool/call': { this.openCalls.set(String(event.data.callId), { callId: String(event.data.callId), name: event.data.name, argsRaw: event.data.arguments, - turn: event.data.turn, step: event.data.step, + turn: event.data.turn, step: event.data.step, time: event.time, callView: view?.for === 'call' ? view.view : null, }) this.callsRev++ @@ -455,7 +455,8 @@ export class Session implements ObservableSnapshot { if (visible) { // Fractional seq: strictly after every event of this turn (all < turn/end seq), before the next turn. this.frozenNodes.push({ - kind: 'assistant', seq: event.seq - 0.9, turn: this.partial.turn, step: this.partial.step, + kind: 'assistant', seq: event.seq - 0.9, time: event.time, + turn: this.partial.turn, step: this.partial.step, blocks, interrupted: true, }) this.frozenRev++ @@ -469,8 +470,10 @@ export class Session implements ObservableSnapshot { this.callsRev++ // The spinner card becomes an interrupted terminal card (never vanishes mid-flow). this.frozenNodes.push({ - kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, callId, + kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, time: event.time, + callId, call: { name: call.name, argsRaw: call.argsRaw }, + callTime: call.time, content: [], isError: true, error: { name: 'Interrupted', code: 'interrupted' }, callView: call.callView, resultView: null, }) diff --git a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx index 2686a59ac2..4d3383b2d1 100644 --- a/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats-bash-sample.spec.tsx @@ -20,7 +20,7 @@ afterEach(cleanup) const SID = 's1' as SessionId const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({ - kind: 'assistant', seq, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }], + kind: 'assistant', seq, time: seq * 1_000, turn, step: seq, blocks: [{ kind: 'text', text: `t${seq}` }], ...(usage === undefined ? {} : { usage }), }) @@ -65,7 +65,7 @@ describe('deriveStats', () => { it('cache hit stays null with no cache accounting; non-assistant nodes ignored', () => { const tool: ToolResultNode = { - kind: 'tool-result', seq: 5, callId: 'c', call: null, content: [], + kind: 'tool-result', seq: 5, time: 5_000, callId: 'c', call: null, callTime: null, content: [], isError: false, callView: null, resultView: null, } const stats = deriveStats([tool, assistant(1, 1)]) @@ -112,8 +112,9 @@ describe('bash sample row', () => { const CHILD = 'child-1' as SessionId const result = (callId: string): ToolResultNode => ({ - kind: 'tool-result', seq: 3, callId, + kind: 'tool-result', seq: 3, time: 3_000, callId, call: { name: 'bash', argsRaw: '{"command":"make build","description":"Build"}' }, + callTime: 2_000, content: [], isError: false, callView: null, resultView: null, }) diff --git a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx index 828cf586fe..a221a4028b 100644 --- a/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-tool-row.spec.tsx @@ -12,12 +12,13 @@ import type { ToolRowOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/ const running = (over?: Partial): RunningToolCall => ({ callId: 'c1', name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}', - turn: 1, step: 1, callView: null, ...over, + turn: 1, step: 1, time: 1_000, callView: null, ...over, }) const result = (over?: Partial): ToolResultNode => ({ - kind: 'tool-result', seq: 10, callId: 'c1', + kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"ls -la","description":"List files"}' }, + callTime: 1_000, content: [], isError: false, callView: null, resultView: null, ...over, }) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 90c2f3090c..3f1db55199 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -54,18 +54,19 @@ function makeSource(init?: Partial) { } const user = (seq: number, text: string): UserMessageNode => ({ - kind: 'user', seq, content: [{ type: 'text', text }] as never, source: null, + kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text }] as never, source: null, }) const assistant = (seq: number, text: string): AssistantMessageNode => ({ - kind: 'assistant', seq, turn: 1, step: 1, blocks: [{ kind: 'text', text }], + kind: 'assistant', seq, time: seq * 1_000, turn: 1, step: 1, blocks: [{ kind: 'text', text }], }) const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ - kind: 'tool-result', seq, callId, + kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, + callTime: seq * 1_000 - 500, content: [], isError: false, callView: null, resultView: null, }) const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({ - callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, callView: null, + callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null, }) /** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */ diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 7f98cdd6ed..66ae3e802c 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -62,8 +62,9 @@ describe('tails', () => { it('a settled others-variant row renders the sparkle icon in the leading slot', () => { const settled: ToolResultNode = { - kind: 'tool-result', seq: 2, callId: 'c5', + kind: 'tool-result', seq: 2, time: 2_000, callId: 'c5', call: { name: 'todo_write', argsRaw: '{"note":"x"}' }, + callTime: 1_000, content: [], isError: false, callView: null, resultView: null, } const props: ToolRowOwnerProps = { @@ -77,8 +78,9 @@ describe('tails', () => { it('BashRow shows the failed pill on error results (root session arm)', () => { const errorResult: ToolResultNode = { - kind: 'tool-result', seq: 1, callId: 'c1', + kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"command":"boom"}' }, + callTime: 500, content: [], isError: true, callView: null, resultView: null, } // Root session (no parentId): the global arm renders, error pill visible. diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index b91fe229c3..10dba860c0 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -165,7 +165,7 @@ describe('DetailsPanel branches', () => { it('shows non-JSON args verbatim (streaming fragment path)', () => { const view = panel({ turnSeq: 1, callId: 'c1', toolName: 'bash' }, { - runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, callView: null }], + runningCalls: [{ callId: 'c1', name: 'bash', argsRaw: '{"cmd": tru', turn: 1, step: 1, time: 1_000, callView: null }], }) expect(view.getByText('{"cmd": tru')).toBeTruthy() }) @@ -176,7 +176,7 @@ describe('DetailsPanel branches', () => { }) it('snapshot updates re-run the material selector through the shallow equality arm', () => { - let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, callView: null }] } as ConversationSnapshot + let snap = { ...snapshotBase(), runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{"a":1}', turn: 1, step: 1, time: 1_000, callView: null }] } as ConversationSnapshot const subs = new Set<() => void>() const source = { getSnapshot: () => snap, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index a4243c8cb7..c923c19269 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -234,10 +234,11 @@ describe('DetailsPanel', () => { it('renders the selected call args and result off the shared store; close fires the injected callback', () => { const { closeDetails } = benchDetails({ nodes: [{ - kind: 'tool-result', callId: 'c1', + kind: 'tool-result', seq: 1, time: 1_000, callId: 'c1', call: { name: 'bash', argsRaw: '{"cmd":"ls"}' }, + callTime: 500, content: [{ type: 'text', text: 'file-a\nfile-b' }], - isError: false, + isError: false, callView: null, resultView: null, }], }, { turnSeq: 1, callId: 'c1' }) expect(screen.getByText('bash')).toBeTruthy() @@ -248,10 +249,10 @@ describe('DetailsPanel', () => { }) it('shows the empty hint without a selection and the running state for open calls', () => { - benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, null) + benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, null) expect(screen.getByText(/点击消息流中的工具行/)).toBeTruthy() cleanup() - benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}' }] }, { turnSeq: 1, callId: 'c9' }) + benchDetails({ runningCalls: [{ callId: 'c9', name: 'bash', argsRaw: '{}', turn: 1, step: 1, time: 1_000, callView: null }] }, { turnSeq: 1, callId: 'c9' }) expect(screen.getByText('运行中…')).toBeTruthy() }) diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 96408ce4b9..e00ec415b7 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -169,6 +169,7 @@ body { --dsw-alias-border-l3: rgba(0, 0, 0, 0.12); --dsw-alias-border-l4: rgba(0, 0, 0, 0.16); --dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-1000); + --dsw-alias-brand-primary-new-colorprimary-new-color: rgb(65, 118, 230); --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-1000); --dsw-alias-brand-text: var(--dsw-static-neutral-bluish-1000); --dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-700); @@ -217,6 +218,7 @@ body { --dsw-alias-state-error-secondary: var(--dsw-static-red-400); --dsw-alias-state-success-primary: var(--dsw-static-green-500); --dsw-alias-state-success-secondary: var(--dsw-static-green-400); + --dsw-alias-state-success-tertiary: var(--dsw-static-green-100); --dsw-alias-state-warn-label: var(--dsw-static-amber-600); --dsw-alias-state-warn-primary: var(--dsw-static-amber-500); --dsw-alias-state-warn-secondary: var(--dsw-static-amber-400); @@ -257,11 +259,12 @@ body[data-ds-dark-theme] { --dsw-alias-border-l3: rgba(255, 255, 255, 0.16); --dsw-alias-border-l4: rgba(255, 255, 255, 0.2); --dsw-alias-brand-primary-invert: var(--dsw-static-neutral-bluish-50); + --dsw-alias-brand-primary-new-colorprimary-new-color: var(--dsw-static-deepseek-450); --dsw-alias-brand-primary: var(--dsw-static-neutral-bluish-50); --dsw-alias-brand-text: var(--dsw-static-neutral-bluish-50); --dsw-alias-button-contrast-fill: var(--dsw-static-neutral-bluish-50); --dsw-alias-button-elevated-fill: var(--dsw-static-neutral-bluish-750); - --dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-950); + --dsw-alias-button-floating-fill: var(--dsw-static-neutral-bluish-850); --dsw-alias-button-floating-hover: var(--dsw-static-neutral-bluish-800); --dsw-alias-button-ghost-active-border: var(--dsw-static-neutral-bluish-600); --dsw-alias-button-ghost-active-fill: var(--dsw-static-neutral-bluish-750); diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index e3c2f6aade..f99a5c8386 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-trajectory -Trajectory/Waterfall placeholder views; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. +Trajectory turn-list chrome (sticky Turn / Message·Step groups / step cells) plus Waterfall placeholder; the pure-consumer minimal plugin exemplar (registers two view tabs into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). Contract: api-contracts v3 §8. ## Model Experience @@ -12,4 +12,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Both views are placeholders by charter** — coarse span derivation with no visual acceptance bar; the real implementations, anchor deep-linking, and span-click selection handoff are the P-III project. +- **In-flight Time stays blank** — `partial` / `runningCalls` rows render with `—` until a live clock policy lands; selected styling is local-only (not wired to chat details); anchor deep-linking remains deferred. diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css new file mode 100644 index 0000000000..1120fe2746 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.module.css @@ -0,0 +1,93 @@ +/* Trajectory step cell — 38px row: index · kind tag · text · optional message + * metrics · elapsed time. */ + +.root { + display: flex; + align-items: center; + box-sizing: border-box; + height: 38px; + padding: 0 8px 0 20px; + gap: 24px; + border-radius: 8px; + border: 1px solid var(--dsw-alias-border-l2); + background: var(--dsw-alias-bg-layer-3); + min-width: 0; +} + +.selected { + border-color: transparent; + box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary-new-colorprimary-new-color); +} + +.index { + flex: none; + width: 24px; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} + +.tagSlot { + flex: none; + width: 80px; + display: flex; + align-items: center; + min-width: 0; +} + +.tag { + display: inline-flex; + align-items: center; + box-sizing: border-box; + height: 22px; + max-width: 100%; + padding: 0 4px; + border-radius: 6px; + font: var(--dsw-font-xs-strong-13); + white-space: nowrap; +} + +.tagUser { + color: var(--dsw-alias-state-success-primary); + background: var(--dsw-alias-state-success-tertiary); +} + +.tagMessage { + color: var(--dsw-alias-brand-primary-new-colorprimary-new-color); + background: var(--dsw-specific-bubble); +} + +.tagTool { + color: var(--dsw-alias-state-warn-label); + background: var(--dsw-alias-state-warn-tertiary); +} + +.text { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-primary); +} + +/* Same column geometry as TrajectoryTurnHeader: 4×71 + 3×12 = 320. */ +.trailing { + flex: none; + display: flex; + align-items: center; + justify-content: flex-end; + width: 320px; + gap: 12px; + min-width: 0; +} + +.metric, +.time { + flex: none; + width: 71px; + text-align: left; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); + white-space: nowrap; +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx new file mode 100644 index 0000000000..de99d027d8 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryCell.tsx @@ -0,0 +1,99 @@ +// TrajectoryCell: one step row in the trajectory list — index, kind tag, +// ellipsis text, optional Message token metrics, and own-duration time. + +import type { HTMLAttributes } from 'react' +import css from './TrajectoryCell.module.css' + +/** Closed set of trajectory step kinds (call+result fold into Tool; no Think). */ +export type TrajectoryCellKind = 'user' | 'message' | 'tool' + +/** Display label per kind (matches the design tags). */ +const KIND_LABEL: Record = { + user: 'User', + message: 'Message', + tool: 'Tool', +} + +const TAG_CLASS: Record = { + user: css.tagUser!, + message: css.tagMessage!, + tool: css.tagTool!, +} + +export interface TrajectoryCellProps extends HTMLAttributes { + /** 1-based step index shown as `#N`. */ + index: number + kind: TrajectoryCellKind + /** Single-line summary; CSS ellipsis when it overflows. */ + text: string + /** + * Own duration in seconds. `null` means no duration to show (em dash) — + * used for in-flight tools and tools missing callTime. + */ + timeSeconds: number | null + /** Message-only: prompt token count. */ + input?: number + /** Message-only: completion token count. */ + output?: number + /** Message-only: reasoning token count (usage column, not a Think cell). */ + think?: number + /** Selected: 2px inset brand-primary-new-color ring (not wired to chat selection yet). */ + selected?: boolean +} + +/** + * Format own-duration for the trailing time column: `—` when unknown, `+Ns` + * or `+N.1s` otherwise. + * @param seconds - duration seconds, or null when absent. + * @returns display string. + */ +export function formatElapsedSeconds(seconds: number | null): string { + if (seconds === null || !Number.isFinite(seconds)) return '—' + const rounded = Math.round(seconds * 10) / 10 + if (Number.isInteger(rounded)) return `+${rounded}s` + return `+${rounded.toFixed(1)}s` +} + +/** + * Render one trajectory step cell. + * @param props - index, kind, text, time, and optional Message metrics. + * @returns the cell element. + */ +export function TrajectoryCell({ + index, + kind, + text, + timeSeconds, + input, + output, + think, + selected = false, + className, + ...rest +}: TrajectoryCellProps) { + const rootClass = [ + css.root, + selected ? css.selected : undefined, + className, + ].filter((c): c is string => c !== undefined).join(' ') + const showMetrics = kind === 'message' + return ( +
+ #{index} + + {KIND_LABEL[kind]} + + {text} + + {showMetrics ? ( + <> + {input ?? ''} + {output ?? ''} + {think ?? ''} + + ) : null} + {formatElapsedSeconds(timeSeconds)} + +
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css new file mode 100644 index 0000000000..6de7074aaa --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.module.css @@ -0,0 +1,27 @@ +/* Message / Step group title row inside a turn body. */ + +.root { + display: flex; + align-items: center; + box-sizing: border-box; + height: 36px; + padding: 0 20px; + gap: 24px; + min-width: 0; +} + +.title { + flex: none; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-primary); +} + +.description { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx new file mode 100644 index 0000000000..90252ce373 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryGroupHeader.tsx @@ -0,0 +1,26 @@ +// TrajectoryGroupHeader: "Message" or "Step N" row with optional description. + +import css from './TrajectoryGroupHeader.module.css' + +export interface TrajectoryGroupHeaderProps { + /** Group title (`Message`, `Step 1`, …). */ + title: string + /** Secondary summary (`49s`, `2.2s skill`, …). */ + description?: string +} + +/** + * Render a Message/Step group header inside a turn body. + * @param props - title and optional description. + * @returns the group header element. + */ +export function TrajectoryGroupHeader({ title, description }: TrajectoryGroupHeaderProps) { + return ( +
+ {title} + {description !== undefined && description !== '' + ? {description} + : null} +
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css new file mode 100644 index 0000000000..c1f243c8b9 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurn.module.css @@ -0,0 +1,16 @@ +/* One turn block: sticky header + padded body with 10px item gap. */ + +.root { + width: 100%; +} + +.body { + display: flex; + flex-direction: column; + gap: 10px; + box-sizing: border-box; + width: 100%; + max-width: 880px; + margin: 0 auto; + padding: 8px 16px 22px; +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx new file mode 100644 index 0000000000..6ebce17731 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx @@ -0,0 +1,26 @@ +// TrajectoryTurn: sticky Turn header plus the padded Message/Step body. + +import type { ReactNode } from 'react' +import { TrajectoryTurnHeader } from './TrajectoryTurnHeader.tsx' +import css from './TrajectoryTurn.module.css' + +export interface TrajectoryTurnProps { + /** 1-based turn index for the sticky header. */ + turn: number + /** Message / Step headers and TrajectoryCell rows. */ + children?: ReactNode +} + +/** + * Render one turn section (sticky header + body). + * @param props - turn index and body children. + * @returns the turn section element. + */ +export function TrajectoryTurn({ turn, children }: TrajectoryTurnProps) { + return ( +
+ +
{children}
+
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css new file mode 100644 index 0000000000..4aaed68551 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.module.css @@ -0,0 +1,48 @@ +/* Sticky turn bar: full-bleed ghost-active fill across the panel; title + + * metric labels sit in a centered 880 content lane (4×71 + 3×12 = 320). */ + +.root { + position: sticky; + top: 0; + z-index: 1; + box-sizing: border-box; + width: 100%; + height: 44px; + background: var(--dsw-alias-button-ghost-active-fill); +} + +.inner { + display: flex; + align-items: center; + justify-content: space-between; + box-sizing: border-box; + width: 100%; + max-width: 880px; + height: 100%; + margin: 0 auto; + padding: 0 16px; +} + +.title { + flex: none; + font: var(--dsw-font-xs-strong-13); + color: var(--dsw-alias-label-primary); +} + +.columns { + flex: none; + display: flex; + align-items: center; + width: 320px; + gap: 12px; + /* Match cell padding-right: 8 so Time lines up with the trailing lane. */ + margin-right: 8px; +} + +.column { + flex: none; + width: 71px; + text-align: left; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-secondary); +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx new file mode 100644 index 0000000000..ba54ed1c34 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/TrajectoryTurnHeader.tsx @@ -0,0 +1,30 @@ +// TrajectoryTurnHeader: sticky per-turn bar with Input/Output/Think/Time labels. + +import css from './TrajectoryTurnHeader.module.css' + +const COLUMN_LABELS = ['Input', 'Output', 'Think', 'Time'] as const + +export interface TrajectoryTurnHeaderProps { + /** 1-based turn index shown as `Turn N`. */ + turn: number +} + +/** + * Render the sticky turn header row. + * @param props.turn - turn index. + * @returns the sticky header element. + */ +export function TrajectoryTurnHeader({ turn }: TrajectoryTurnHeaderProps) { + return ( +
+
+ Turn {turn} + +
+
+ ) +} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 0ccb298801..45277eb628 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,30 +1,40 @@ -// TrajectoryView: P-I placeholder body for the trajectory tab — span stats -// header over a per-turn span list with node-count weights (no timing data -// exists yet; deviation ledger #3 defers real rendering to P-III). +// TrajectoryView: sticky Turn sections with Message/Step groups and step cells. import { useMemo } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { deriveSpans } from './spans.ts' -import { TrajectoryStatsHeader } from './TrajectoryStatsHeader.tsx' +import { TrajectoryCell } from './TrajectoryCell.tsx' +import { TrajectoryGroupHeader } from './TrajectoryGroupHeader.tsx' +import { TrajectoryTurn } from './TrajectoryTurn.tsx' +import { deriveTrajectoryLayout } from './layout.ts' import css from './views.module.css' export function TrajectoryView({ useSession }: ConvViewProps) { const nodes = useSession((s) => s.nodes) - const spans = useMemo(() => deriveSpans(nodes), [nodes]) - if (spans.length === 0) return

暂无轨迹数据

+ const partial = useSession((s) => s.partial) + const runningCalls = useSession((s) => s.runningCalls) + const turns = useMemo( + () => deriveTrajectoryLayout({ nodes, partial, runningCalls }), + [nodes, partial, runningCalls], + ) + if (turns.length === 0) { + return

暂无轨迹数据

+ } return ( - <> - -
- {spans.map((span) => ( -
- turn {span.turn} - - {span.steps} steps · {span.calls} calls · {span.nodes} nodes - -
- ))} -
- +
+ {turns.map((turn) => ( + + {turn.groups.flatMap((group) => [ + , + ...group.cells.map((cell) => ( + + )), + ])} + + ))} +
) } diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 3979bfd91b..4a902fe9ff 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -24,8 +24,8 @@ export const inject = ['slots', 'conversation'] /** * Client plugin body: register the trajectory and waterfall view tabs. The * registrations ride the slot service's effect wrapper (plugin unload - * removes both tabs); the span stats header renders inside each view body - * (the chrome attachment mechanism retired with the view ring). + * removes both tabs). Trajectory owns its turn list in-body; Waterfall keeps + * the span stats header inside its body (chrome attachment retired). * @param ctx - client root context. */ export function apply(ctx: Context): void { diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts new file mode 100644 index 0000000000..03bfcb6893 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -0,0 +1,374 @@ +/** + * Trajectory list fold: expand assistant blocks, attach usage to Message, + * own-duration times, in-flight partial/runningCalls, and group descriptions. + */ +import type { + AssistantMessageNode, + ConversationSnapshot, + ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { TrajectoryCellProps } from './TrajectoryCell.tsx' + +/** One Message or Step group inside a turn. */ +export interface TrajectoryGroupModel { + title: string + description?: string + cells: readonly TrajectoryCellProps[] +} + +/** One sticky-turn section. */ +export interface TrajectoryTurnModel { + turn: number + groups: readonly TrajectoryGroupModel[] +} + +/** Snapshot slice the trajectory view folds. */ +export interface TrajectoryLayoutInput { + nodes: ConversationSnapshot['nodes'] + partial: ConversationSnapshot['partial'] + runningCalls: ConversationSnapshot['runningCalls'] +} + +interface UsageLike { + inputTokens?: number + outputTokens?: number + reasoningTokens?: number +} + +/** Cell plus absolute ms for group wall-span descriptions. */ +interface LaidCell { + cell: TrajectoryCellProps + absTime: number | null + toolName?: string + callId?: string +} + +/** + * Fold a snapshot into turn → Message/Step groups with expanded cells. + * @param input - nodes plus in-flight partial/runningCalls. + * @returns turns ordered by first appearance. + */ +export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] { + const { nodes, partial, runningCalls } = input + const resultByCall = indexResults(nodes) + const turns = new Map }>() + let index = 0 + let prevAbsTime: number | null = null + + const bucket = (turn: number) => { + let entry = turns.get(turn) + if (entry === undefined) { + entry = { message: [], steps: new Map() } + turns.set(turn, entry) + } + return entry + } + + const pushMessage = (turn: number, laid: LaidCell) => { + bucket(turn).message.push(laid) + } + const pushStep = (turn: number, step: number, laid: LaidCell) => { + const steps = bucket(turn).steps + const list = steps.get(step) ?? [] + list.push(laid) + steps.set(step, list) + } + + for (const node of nodes) { + if (node.kind === 'user' || node.kind === 'steering') { + const turn = node.kind === 'steering' ? node.turn : 0 + pushMessage(turn, { + absTime: finiteTime(node.time), + cell: { + index: ++index, kind: 'user', text: summarizeContent(node.content), + timeSeconds: 0, + }, + }) + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + continue + } + if (node.kind === 'assistant') { + const laidList = expandAssistant(node, index + 1, prevAbsTime, resultByCall) + for (const laid of laidList) { + if (node.step > 0) pushStep(node.turn, node.step, laid) + else pushMessage(node.turn, laid) + } + const last = laidList[laidList.length - 1] + if (last !== undefined) index = last.cell.index + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + continue + } + if (node.kind === 'tool-result') { + if (!callEmittedInAssistant(nodes, node.callId)) { + const toolName = node.call?.name + pushStep(0, 1, { + absTime: finiteTime(node.callTime ?? node.time), + ...(toolName !== undefined ? { toolName } : {}), + callId: node.callId, + cell: { + index: ++index, + kind: 'tool', + text: node.call !== null + ? summarizeCall(node.call.name, node.call.argsRaw) + : summarizeResult(node), + timeSeconds: durationSeconds(node.time, node.callTime), + }, + }) + } + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + } + } + + if (partial !== null) { + const fake: AssistantMessageNode = { + kind: 'assistant', seq: Number.MAX_SAFE_INTEGER, time: 0, + turn: partial.turn, step: partial.step, blocks: partial.blocks, + } + const laidList = expandAssistant(fake, index + 1, prevAbsTime, resultByCall, { streaming: true }) + for (const laid of laidList) { + if (partial.step > 0) pushStep(partial.turn, partial.step, laid) + else pushMessage(partial.turn, laid) + } + const last = laidList[laidList.length - 1] + if (last !== undefined) index = last.cell.index + } + + const seenCalls = collectCallIds(turns) + for (const call of runningCalls) { + if (seenCalls.has(call.callId)) continue + pushStep(call.turn, call.step > 0 ? call.step : 1, { + absTime: null, + toolName: call.name, + callId: call.callId, + cell: { + index: ++index, + kind: 'tool', + text: summarizeCall(call.name, call.argsRaw), + timeSeconds: null, + }, + }) + } + + const prologue = turns.get(0) + if (prologue !== undefined) { + turns.delete(0) + const emptyTurn = (): { message: LaidCell[]; steps: Map } => ({ + message: [], + steps: new Map(), + }) + const first = turns.get(1) ?? emptyTurn() + first.message = [...prologue.message, ...first.message] + for (const [step, cells] of prologue.steps) { + const existing = first.steps.get(step) ?? [] + first.steps.set(step, [...cells, ...existing]) + } + turns.set(1, first) + } + + return [...turns.entries()] + .sort(([a], [b]) => a - b) + .map(([turn, entry]) => toTurnModel(turn, entry)) +} + +function toTurnModel( + turn: number, + entry: { message: LaidCell[]; steps: Map }, +): TrajectoryTurnModel { + const groups: TrajectoryGroupModel[] = [] + if (entry.message.length > 0) { + const description = groupDescription(entry.message) + groups.push({ + title: 'Message', + ...(description !== undefined ? { description } : {}), + cells: entry.message.map(l => l.cell), + }) + } + for (const step of [...entry.steps.keys()].sort((a, b) => a - b)) { + const laid = entry.steps.get(step) ?? [] + const description = groupDescription(laid) + groups.push({ + title: `Step ${step}`, + ...(description !== undefined ? { description } : {}), + cells: laid.map(l => l.cell), + }) + } + return { turn, groups } +} + +/** Wall-span duration + tool histogram, e.g. `1.5s bash×6`. */ +function groupDescription(laid: readonly LaidCell[]): string | undefined { + const parts: string[] = [] + // Tool rows contribute start (absTime) and end (start + own duration) so a + // single Tool cell still spans call→result for the group wall clock. + const times: number[] = [] + for (const l of laid) { + if (l.absTime === null || !Number.isFinite(l.absTime)) continue + times.push(l.absTime) + if (l.cell.kind === 'tool' && l.cell.timeSeconds !== null && Number.isFinite(l.cell.timeSeconds)) { + times.push(l.absTime + l.cell.timeSeconds * 1000) + } + } + if (times.length >= 2) { + const span = formatGroupDuration((Math.max(...times) - Math.min(...times)) / 1000) + if (span !== undefined) parts.push(span) + } else if (times.length === 1) { + const own = laid.find(l => l.absTime === times[0])?.cell.timeSeconds + const span = own !== null && own !== undefined ? formatGroupDuration(own) : undefined + if (span !== undefined) parts.push(span) + } + const tools = new Map() + for (const l of laid) { + if (l.toolName === undefined || l.cell.kind !== 'tool') continue + tools.set(l.toolName, (tools.get(l.toolName) ?? 0) + 1) + } + for (const [name, count] of tools) { + parts.push(count > 1 ? `${name}×${count}` : name) + } + return parts.length === 0 ? undefined : parts.join(' ') +} + +function formatGroupDuration(seconds: number): string | undefined { + if (!Number.isFinite(seconds)) return undefined + const rounded = Math.round(seconds * 10) / 10 + if (Number.isInteger(rounded)) return `${rounded}s` + return `${rounded.toFixed(1)}s` +} + +/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */ +function durationSeconds(later: number, earlier: number | null): number | null { + if (earlier === null || !Number.isFinite(later) || !Number.isFinite(earlier)) return null + return Math.max(0, (later - earlier) / 1000) +} + +/** Epoch-ms usable as an absolute time, else null. */ +function finiteTime(time: number): number | null { + return Number.isFinite(time) ? time : null +} + +function expandAssistant( + node: AssistantMessageNode, + startIndex: number, + prevAbsTime: number | null, + results: Map, + opts?: { streaming?: boolean }, +): LaidCell[] { + const out: LaidCell[] = [] + let index = startIndex - 1 + const usage = node.usage as UsageLike | undefined + const streaming = opts?.streaming === true + const messageDuration = streaming ? null : durationSeconds(node.time, prevAbsTime) + const nodeAbs = streaming ? null : finiteTime(node.time) + let usageAttached = false + + for (const block of node.blocks) { + // Reasoning blocks are skipped: no block-level clock, so no Think cell. + if (block.kind === 'reasoning') continue + if (block.kind === 'text') { + if (block.text === '' && streaming) continue + const cell: TrajectoryCellProps = { + index: ++index, kind: 'message', text: summarizeText(block.text), + timeSeconds: messageDuration, + } + if (!usageAttached && usage !== undefined) { + if (usage.inputTokens !== undefined) cell.input = usage.inputTokens + if (usage.outputTokens !== undefined) cell.output = usage.outputTokens + if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens + usageAttached = true + } + out.push({ absTime: nodeAbs, cell }) + continue + } + if (block.kind === 'tool-call') { + const result = results.get(block.callId) + const toolDuration = streaming || result === undefined + ? null + : durationSeconds(result.time, result.callTime) + const callAbs = streaming + ? null + : (result?.callTime !== null && result?.callTime !== undefined && Number.isFinite(result.callTime) + ? result.callTime + : nodeAbs) + out.push({ + absTime: callAbs, + toolName: block.name, + callId: block.callId, + cell: { + index: ++index, kind: 'tool', + text: summarizeCall(block.name, block.argsRaw), + timeSeconds: toolDuration, + }, + }) + } + } + + if (out.length === 0 && !streaming) { + out.push({ + absTime: nodeAbs, + cell: { index: ++index, kind: 'message', text: '', timeSeconds: messageDuration }, + }) + } + return out +} + +function indexResults(nodes: ConversationSnapshot['nodes']): Map { + const map = new Map() + for (const node of nodes) { + if (node.kind === 'tool-result') map.set(node.callId, node) + } + return map +} + +function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean { + for (const node of nodes) { + if (node.kind !== 'assistant') continue + if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true + } + return false +} + +function collectCallIds( + turns: Map }>, +): Set { + const ids = new Set() + for (const entry of turns.values()) { + for (const laid of entry.message) { + if (laid.callId !== undefined) ids.add(laid.callId) + } + for (const list of entry.steps.values()) { + for (const laid of list) { + if (laid.callId !== undefined) ids.add(laid.callId) + } + } + } + return ids +} + +function summarizeCall(name: string, argsRaw: string): string { + const args = argsRaw.replace(/\s+/g, ' ').trim() + if (args === '') return name + const clipped = args.length > 72 ? `${args.slice(0, 71)}…` : args + return `${name} · ${clipped}` +} + +function summarizeResult(node: ToolResultNode): string { + if (node.isError) { + return node.error?.code ?? 'error' + } + for (const block of node.content) { + if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') { + return summarizeText(block.text) + } + } + return node.call?.name ?? node.callId +} + +function summarizeContent(content: readonly { type: string; text?: string }[]): string { + for (const block of content) { + if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text) + } + return '' +} + +function summarizeText(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 951a5e2705..d3089b3568 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -1,25 +1,34 @@ +/* Full-bleed scroll host so Turn sticky bars can paint edge-to-edge; + * cell content width is capped on the turn body (max 880). */ .root { - padding: 16px; overflow-y: auto; + height: 100%; + min-height: 0; + width: 100%; + box-sizing: border-box; color: var(--dsw-alias-label-primary); - font-size: 13px; + background: var(--dsw-specific-sidebar-fill); } .empty { + padding: 16px; color: var(--dsw-alias-label-tertiary); + font: var(--dsw-font-xs-13); } +/* Waterfall placeholder rows (shared module). */ .row { display: flex; align-items: center; gap: 8px; - padding: 4px 0; + padding: 4px 16px; } .turnTag { flex: none; width: 64px; color: var(--dsw-alias-label-secondary); + font: var(--dsw-font-xs-13); } .bar { @@ -29,9 +38,10 @@ } .barCalls { - background: var(--dsw-alias-brand-primary); + background: var(--dsw-alias-brand-primary-new-colorprimary-new-color); } .meta { color: var(--dsw-alias-label-caption); + font: var(--dsw-font-xs-13); } diff --git a/packages/client/ui-trajectory/tests/cell.spec.tsx b/packages/client/ui-trajectory/tests/cell.spec.tsx new file mode 100644 index 0000000000..d9c9004622 --- /dev/null +++ b/packages/client/ui-trajectory/tests/cell.spec.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +/** + * TrajectoryCell presentation: kind tags, ellipsis-hosting text, Message + * metric columns, own-duration formatting, and selected ring. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { + formatElapsedSeconds, + TrajectoryCell, + type TrajectoryCellKind, +} from '../src/client/TrajectoryCell.tsx' + +afterEach(cleanup) + +describe('formatElapsedSeconds', () => { + it('formats known durations and uses an em dash when absent', () => { + expect(formatElapsedSeconds(null)).toBe('—') + expect(formatElapsedSeconds(235)).toBe('+235s') + expect(formatElapsedSeconds(235.0)).toBe('+235s') + expect(formatElapsedSeconds(235.2)).toBe('+235.2s') + expect(formatElapsedSeconds(235.25)).toBe('+235.3s') + expect(formatElapsedSeconds(0)).toBe('+0s') + expect(formatElapsedSeconds(Number.NaN)).toBe('—') + }) +}) + +describe('TrajectoryCell', () => { + it('renders index, kind tag, text, and time for a Tool row', () => { + render( + , + ) + expect(screen.getByText('#6')).toBeTruthy() + expect(screen.getByText('Tool')).toBeTruthy() + expect(screen.getByText('bash · Read src/index.ts')).toBeTruthy() + expect(screen.getByText('+5s')).toBeTruthy() + }) + + it('Message rows expose Input / Output / Think metric columns before time', () => { + const { container } = render( + , + ) + expect(screen.getByText('Message')).toBeTruthy() + expect(screen.getByText('136')).toBeTruthy() + expect(screen.getByText('381')).toBeTruthy() + expect(screen.getByText('155')).toBeTruthy() + expect(screen.getByText('+235.2s')).toBeTruthy() + const texts = [...container.querySelectorAll('span')].map((el) => el.textContent) + expect(texts.indexOf('136')).toBeLessThan(texts.indexOf('381')) + expect(texts.indexOf('381')).toBeLessThan(texts.indexOf('155')) + expect(texts.indexOf('155')).toBeLessThan(texts.indexOf('+235.2s')) + }) + + it('selected marks the row for the brand-primary inset ring', () => { + const { container } = render( + , + ) + expect(container.firstElementChild?.getAttribute('data-selected')).toBe('true') + }) + + it.each([ + ['user', 'User'], + ['tool', 'Tool'], + ] as const)('kind %s shows the %s tag and no metric columns', (kind: TrajectoryCellKind, label: string) => { + const { container } = render( + , + ) + expect(screen.getByText(label)).toBeTruthy() + expect(container.querySelector('[data-kind]')?.getAttribute('data-kind')).toBe(kind) + expect(screen.queryByText('1')).toBeNull() + expect(screen.queryByText('2')).toBeNull() + expect(screen.queryByText('3')).toBeNull() + }) +}) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx new file mode 100644 index 0000000000..4dc395221d --- /dev/null +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +/** + * Trajectory turn chrome and layout fold: expand blocks, usage on Message, + * tool own-duration, group wall-span descriptions, in-flight rows. + */ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client' +import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' +import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' +import { TrajectoryTurnHeader } from '../src/client/TrajectoryTurnHeader.tsx' +import { deriveTrajectoryLayout } from '../src/client/layout.ts' + +afterEach(cleanup) + +describe('TrajectoryTurnHeader', () => { + it('renders Turn N and the four metric column labels', () => { + render() + expect(screen.getByText('Turn 1')).toBeTruthy() + expect(screen.getByText('Input')).toBeTruthy() + expect(screen.getByText('Output')).toBeTruthy() + expect(screen.getByText('Think')).toBeTruthy() + expect(screen.getByText('Time')).toBeTruthy() + }) +}) + +describe('TrajectoryGroupHeader', () => { + it('renders title and optional description', () => { + render() + expect(screen.getByText('Step 1')).toBeTruthy() + expect(screen.getByText('2.2s skill')).toBeTruthy() + }) + + it('omits the description node when absent', () => { + const { container } = render() + expect(screen.getByText('Message')).toBeTruthy() + expect(container.querySelectorAll('span')).toHaveLength(1) + }) +}) + +describe('TrajectoryTurn', () => { + it('wraps a sticky header and body children', () => { + render( + + + , + ) + expect(screen.getByText('Turn 3')).toBeTruthy() + expect(screen.getByText('Message')).toBeTruthy() + expect(screen.getByText('49s')).toBeTruthy() + }) +}) + +describe('deriveTrajectoryLayout', () => { + it('expands assistant blocks, hangs usage on Message, and folds call+result into Tool', () => { + const nodes = [ + { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hello' }], source: null }, + { + kind: 'assistant', seq: 2, time: 6_000, turn: 1, step: 1, + blocks: [ + { kind: 'reasoning', text: 'thinking…' }, + { kind: 'text', text: 'I will run bash' }, + { kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{"command":"ls"}' }, + ], + usage: { inputTokens: 10, outputTokens: 20, reasoningTokens: 5 }, + }, + { + kind: 'tool-result', seq: 3, time: 7_500, callId: 'c1', + call: { name: 'bash', argsRaw: '{"command":"ls"}' }, callTime: 6_200, + content: [{ type: 'text', text: 'a.txt' }], isError: false, callView: null, resultView: null, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns).toHaveLength(1) + expect(turns[0]?.turn).toBe(1) + const kinds = turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.kind)) + expect(kinds).toEqual(['user', 'message', 'tool']) + const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message') + expect(message).toMatchObject({ + input: 10, output: 20, think: 5, timeSeconds: 5, + }) + const tool = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'tool') + expect(tool?.text).toBe('bash · {"command":"ls"}') + expect(tool?.timeSeconds).toBe(1.3) + }) + + it('adds runningCalls not already present and leaves their time blank', () => { + const turns = deriveTrajectoryLayout({ + nodes: [] as unknown as ConversationSnapshot['nodes'], + partial: null, + runningCalls: [{ + callId: 'r1', name: 'bash', argsRaw: '{"command":"pwd"}', + turn: 1, step: 2, time: 9_000, callView: null, + }], + }) + expect(turns[0]?.groups.map((g) => g.title)).toEqual(['Step 2']) + expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({ + kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null, + }) + }) + + it('omits duration when node times are missing instead of rendering NaN', () => { + const nodes = [ + { kind: 'user', seq: 1, content: [{ type: 'text', text: 'hi' }], source: null }, + { + kind: 'assistant', seq: 2, turn: 1, step: 1, + blocks: [ + { kind: 'reasoning', text: '…' }, + { kind: 'text', text: 'ok' }, + ], + usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 3 }, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const cells = turns[0]?.groups.flatMap((g) => g.cells) ?? [] + expect(cells.find((c) => c.kind === 'message')?.timeSeconds).toBeNull() + expect(turns[0]?.groups.find((g) => g.title === 'Step 1')?.description).toBeUndefined() + }) + + it('builds a wall-span step description with a tool histogram', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, + blocks: [ + { kind: 'tool-call', callId: 'a', name: 'bash', argsRaw: '{}' }, + { kind: 'tool-call', callId: 'b', name: 'bash', argsRaw: '{}' }, + ], + }, + { + kind: 'tool-result', seq: 2, time: 2_500, callId: 'a', + call: { name: 'bash', argsRaw: '{}' }, callTime: 1_100, + content: [], isError: false, callView: null, resultView: null, + }, + { + kind: 'tool-result', seq: 3, time: 4_000, callId: 'b', + call: { name: 'bash', argsRaw: '{}' }, callTime: 2_600, + content: [], isError: false, callView: null, resultView: null, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2') + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index a3ac84738e..4818e67db5 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -3,9 +3,9 @@ * View registration acceptance on the real framework stack: the plugin fiber * registers trajectory/waterfall into a real SlotsService view ring, tabs * switch inside ConversationRoot (renderSlot share driven by the same tab - * projection apply uses) without collapsing chat, the span stats header - * renders inside both view bodies, and fiber disposal removes both tabs. - * Span derivation edge cases ride along. + * projection apply uses) without collapsing chat, trajectory renders the + * turn-list chrome (no span stats bar), waterfall keeps in-body stats, and + * fiber disposal removes both tabs. Span derivation edge cases ride along. */ import { Context } from 'cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -36,19 +36,26 @@ afterEach(cleanup) // The chat store persists under its declared key; clear so one case's active // view cannot rehydrate into the next. beforeEach(() => { - localStorage.clear() + // Node 22+ exposes an experimental localStorage global that is undefined + // without --localstorage-file; only clear when a real Storage is present. + if (typeof localStorage !== 'undefined') localStorage.clear() }) /** Node fixture: user prologue, two turns, one tool result inside turn 1. */ const NODES = [ - { kind: 'user', seq: 1, content: [], source: null }, - { kind: 'assistant', seq: 2, turn: 1, step: 1, blocks: [] }, - { kind: 'tool-result', seq: 3, callId: 'c1', call: null, content: [], isError: false, callView: null, resultView: null }, - { kind: 'assistant', seq: 4, turn: 2, step: 1, blocks: [] }, + { kind: 'user', seq: 1, time: 1_000, content: [], source: null }, + { kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [] }, + { + kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', call: null, callTime: null, + content: [], isError: false, callView: null, resultView: null, + }, + { kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 1, blocks: [] }, ] as unknown as ConversationSnapshot['nodes'] function fakeSession(nodes: ConversationSnapshot['nodes']) { - const store = createSnapshotStore<{ nodes: ConversationSnapshot['nodes'] }>({ nodes }) + const store = createSnapshotStore({ + nodes, partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], + }) return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } } @@ -99,8 +106,9 @@ function tabsOf(slots: SlotsService): ViewTab[] { /** Mount ConversationRoot over the ring ledger with an outlet-faithful renderSlot. */ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) { - const sessionSnapshot = createSnapshotStore<{ running: boolean; removed: boolean; promptError: null; nodes: ConversationSnapshot['nodes'] }>({ + const sessionSnapshot = createSnapshotStore({ running: false, removed: false, promptError: null, nodes, + partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], }) const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession const chat = createChatStore().create() @@ -158,17 +166,20 @@ describe('plugin registration', () => { }) describe('tab switching in ConversationRoot', () => { - it('renders all three tabs, defaults to chat, and switches to trajectory with its header stats', async () => { + it('renders all three tabs, defaults to chat, and switches to trajectory without stats chrome', async () => { const b = await bench() mount(b.slots) expect(screen.getByTestId('chat-body')).toBeTruthy() expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall']) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - // In-body header stats over NODES: turns 0/1/2, 2 assistant steps, 1 tool call. - expect(screen.getByText('3 turns · 2 steps · 1 tool calls')).toBeTruthy() - expect(screen.getByText('turn 0')).toBeTruthy() - expect(screen.getByText('1 steps · 1 calls · 2 nodes')).toBeTruthy() + // Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body. + expect(screen.queryByText(/turns ·/)).toBeNull() + expect(screen.getByText('Turn 1')).toBeTruthy() + expect(screen.getByText('Turn 2')).toBeTruthy() + expect(screen.getAllByText('Message').length).toBeGreaterThan(0) + expect(screen.getAllByText('Step 1').length).toBeGreaterThan(0) + expect(screen.getAllByText('Input').length).toBeGreaterThan(0) expect(screen.queryByTestId('chat-body')).toBeNull() }) From a4b4a4c53df965982234a3b7f8b6e1cfb0b81c3f Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 13:36:27 +0800 Subject: [PATCH 05/15] fix: type check --- .../tests/chat-toolview-slot.spec.tsx | 3 ++- .../ui-conversation/tests/skeleton.spec.tsx | 23 +++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 69cb2cfa09..5d2b3408a2 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -31,8 +31,9 @@ beforeEach(() => { }) const toolResult = (seq: number, callId: string, name: string, args = '{"command":"make build","description":"Build"}'): ToolResultNode => ({ - kind: 'tool-result', seq, callId, + kind: 'tool-result', seq, time: seq * 1_000, callId, call: { name, argsRaw: args }, + callTime: seq * 1_000 - 500, content: [], isError: false, callView: null, resultView: null, }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index c923c19269..55cb46be2c 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -33,8 +33,27 @@ beforeEach(() => { /** Minimal conversation snapshot slice the skeleton reads. */ interface FakeSnapshot { - nodes: readonly { kind: string; callId?: string; call?: { name: string; argsRaw: string } | null; content?: readonly { type: string; text?: string }[]; isError?: boolean }[] - runningCalls: readonly { callId: string; name: string; argsRaw: string }[] + nodes: readonly { + kind: string + seq?: number + time?: number + callId?: string + call?: { name: string; argsRaw: string } | null + callTime?: number | null + content?: readonly { type: string; text?: string }[] + isError?: boolean + callView?: null + resultView?: null + }[] + runningCalls: readonly { + callId: string + name: string + argsRaw: string + turn?: number + step?: number + time?: number + callView?: null + }[] running: boolean removed: boolean promptError: { op: 'send' | 'stop'; error: { message: string; code: string } } | null From aeb8b1f486f457bc44081667e82e5c9bb5b67606 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 14:09:00 +0800 Subject: [PATCH 06/15] feat: implement new session behavior to clear selection and show empty state - Added bilingual notes for the new session feature, detailing the transition to an empty state upon session creation. - Updated `SessionsService` to include a `clear()` method that resets the current selection and persists the empty state. - Enhanced the `EmptyState` component to reflect the new design, including workspace selection and input handling. - Modified CSS styles for improved layout and visual consistency in the empty state. - Updated tests to cover the new session clearing functionality and its effects on the UI. --- ...ew-session-clears-to-empty-state.i18n.yaml | 6 + ...07-24-new-session-clears-to-empty-state.md | 23 +++ ...24-new-session-clears-to-empty-state.zh.md | 23 +++ .../runtime/src/client/sessions/service.ts | 20 +- .../runtime/tests/sessions-service.spec.ts | 20 ++ .../src/client/skeleton/EmptyState.module.css | 105 ++++++++--- .../src/client/skeleton/EmptyState.tsx | 172 ++++++++++++------ .../src/client/skeleton/InputBar.module.css | 76 +++++++- .../src/client/skeleton/InputBar.tsx | 109 ++++++++--- .../ui-conversation/tests/input-bar.spec.tsx | 42 ++++- .../tests/skeleton-branches.spec.tsx | 15 +- .../ui-conversation/tests/skeleton.spec.tsx | 22 ++- packages/client/ui-sidebar/README.md | 2 +- .../ui-sidebar/src/client/contract/slots.ts | 4 +- .../client/ui-sidebar/src/client/index.ts | 12 +- .../client/ui-sidebar/tests/apply.spec.tsx | 16 +- 16 files changed, 532 insertions(+), 135 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml new file mode 100644 index 0000000000..5f41ac4b70 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-24-new-session-clears-to-empty-state.md: d730f3e0b658ea66b6026593f37a97893e32a4db +2026-07-24-new-session-clears-to-empty-state.zh.md: 602a2b774b569cfef0adcc253fe751f86b14f895 diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md new file mode 100644 index 0000000000..d730f3e0b6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md @@ -0,0 +1,23 @@ +# Agent Note: New Session clears onto the empty-state launch + +Status: implemented + +English | [中文](2026-07-24-new-session-clears-to-empty-state.zh.md) + +## Problem + +Sidebar "New Session" created and opened a blank session immediately, so the center column showed `ConversationRoot` with an empty transcript and the resident composer. The Figma NEW SESSION screen (`EmptyState` + shared `InputBar` hero) only rendered when `sessions.current` was already undefined, so the launch page was unreachable from the primary creation control. + +## Decision + +`SessionsService.clear()` wipes the persisted selection and `list.current`. Top-level sidebar creation entries (`onCreate()` with no cwd — New Session and New Workspace) call `clear()` so `AppFrame` renders `conversation.empty`. The empty state's first send still runs `conversation.startSession` (create → open → send) and reuses the same `InputBar` component as the resident composer (`variant="hero"`). Per-project "+" (`onCreate(cwd)`) keeps create-then-open until the empty-state picker can accept a seeded cwd. + +## Alternatives considered + +**Keep create-then-open for New Session and add a second empty chrome inside ConversationRoot when the transcript is empty.** Rejected: that duplicates the launch InputBar and breaks the empty→content ruling that one InputBar moves position rather than swapping components. + +**Route New Session through a dedicated route or slot outside selection.** Rejected for this pass: `conversation.empty` already owns the launch UI; clearing `current` is the existing empty branch. + +## Consequences + +New Session no longer mints a host session until the first send. Reloading after clear stays on the empty state. Project-scoped "+" still creates immediately. `EmptyState` stacks the Figma hero as fish + title, a Menu-backed workspace chip ("New Workspace" / basename / free-form path) above the card, then shared `InputBar` (`variant="hero"`), with a soft ellipse glow (figma 313:14109) centered behind the picker + card and width-locked to the card (`1051/776`) so it scales with it. `InputBar` paints the bottom chrome (attach / Plan / Read-only / model) with local native `` 状态——host 侧的 plan、access、model 接缝仍未接线。 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index b9116971d0..324c574474 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -95,9 +95,10 @@ export class SessionsService { /** * Persisted selection cell (the durable half of `list.current`). Private on * purpose: reads go through the list snapshot; writes through {@link - * SessionsService.open}. Projection validates it against the live list - * instead of destructively pruning, so a selection survives transient list - * states (reconnect re-pull) and resurfaces when its session returns. + * SessionsService.open} / {@link SessionsService.clear}. Projection + * validates it against the live list instead of destructively pruning, so a + * selection survives transient list states (reconnect re-pull) and + * resurfaces when its session returns. */ private readonly selection: SnapshotStore<{ sessionId?: SessionId }> @@ -137,7 +138,7 @@ export class SessionsService { /** * Select a session as current. Unknown ids fail loud instead of navigating - * nowhere (the sole selection write path). + * nowhere. * @param id - session id (must exist in the list store). */ open(id: SessionId): void { @@ -148,6 +149,17 @@ export class SessionsService { this.list.update((draft) => { draft.current = id }) } + /** + * Clear the current selection so the layout shows the no-session empty + * state. Wipes the persisted selection too — a reload stays on empty until + * the user opens or starts a session. Staging holds the previous occupant + * across the blank (same masked-gap rule as a transient list miss). + */ + clear(): void { + this.selection.set({}) + this.list.update((draft) => { draft.current = undefined }) + } + /** * Create a session on the host. * @param opts - creation options (project directory). diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 97f223548c..33cfce43bb 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -133,6 +133,26 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone }) + it('clear() blanks list.current and the persisted selection', async () => { + const storage = new Map() + vi.stubGlobal('localStorage', { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, v) }, + removeItem: (k: string) => { storage.delete(k) }, + clear: () => { storage.clear() }, + }) + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + expect(storage.get('dsh.sessions.current')).toContain('s1') + b.svc.clear() + expect(b.svc.list.getSnapshot().current).toBeUndefined() + // Persisted wipe: a fresh service with the same storage stays on empty. + const again = bench() + await feedList(again, [{ id: 's1' }]) + expect(again.svc.list.getSnapshot().current).toBeUndefined() + }) + it('masks (not destroys) the selection while its session is off the list', async () => { const b = bench() await feedList(b, [{ id: 's1' }, { id: 's2' }]) diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css index fbe2f25c09..53e618b122 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css @@ -1,6 +1,6 @@ -/* NEW SESSION hero: headline over the shared InputBar card, centered in the - conversation column. The card is the same component as the composer — - only positioning lives here. */ +/* NEW SESSION hero (figma 313:14149): fish + title, workspace chip above the + shared InputBar card. The input itself is InputBar — only stack geometry + lives here. */ .root { display: flex; @@ -11,16 +11,18 @@ padding: 24px; } -/* figma hero group 34:10409: headline block sits 36px above the input card. */ -.card { +/* Cap matches InputBar card width (776). Glow may paint past the sides. */ +.stack { display: flex; flex-direction: column; - gap: 36px; + align-items: stretch; + gap: 40px; width: 100%; max-width: 776px; + overflow: visible; } -/* figma 34:10411: fish + title row, gap 10, centered; title 26/32 wt600 (34:10414). */ +/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600. */ .headline { display: flex; align-items: center; @@ -32,37 +34,98 @@ color: var(--dsw-alias-label-primary); } -/* figma 34:10412/10413: brand-blue vector. */ +/* figma fish fill rides business blue. */ .fish { flex: none; color: var(--dsw-alias-state-business-primary); } -.picker { +/* Workspace row sits 12px above the input card (figma y80 → y112). Glow is + centered on this block so it stays under the picker + InputBar together. */ +.body { + position: relative; + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + overflow: visible; +} + +/* Design input 776 → glow SVG 1051×468 (ellipse 851×268 + blur pad). */ +.glow { + position: absolute; + left: 50%; + top: 50%; + z-index: 0; + width: calc(100% * 1051 / 776); + aspect-ratio: 1051 / 468; + transform: translate(-50%, -50%); + pointer-events: none; +} + +.body > :not(.glow) { + position: relative; + z-index: 1; +} + +.workspaceRow { display: flex; align-items: center; min-width: 0; + /* Align with InputBar's left chrome (card pad 10 + attach). */ + padding-left: 10px; } -.select, -.customInput { - max-width: 320px; - padding: 4px 10px; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 12px; - background: var(--dsw-alias-bg-base); - font-size: 13px; +/* Folder + "New Workspace" + chevron (figma workspace trigger). */ +.workspace { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + height: 28px; + padding: 0 4px 0 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-primary); + font-size: 14px; line-height: 20px; - color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.workspace:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.folder { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.workspaceLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-caption); } .customInput { - width: 320px; + width: min(320px, 100%); + height: 28px; + padding: 0 10px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 8px; outline: none; + background: var(--dsw-alias-bg-base); + font-size: 14px; + line-height: 20px; + color: var(--dsw-alias-label-primary); } .customInput:focus { - /* Business blue, not brand-primary: that token resolves to ink in this sheet. */ border-color: var(--dsw-alias-state-business-primary); - color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index 420ff7a622..edcf0cbad2 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -1,21 +1,27 @@ -// EmptyState (figma NEW SESSION screen): centered hero card built around the -// SAME InputBar component the resident composer uses (the empty→content -// transition is one component changing position, never a swap). Project -// picker: cwd set derived in-component from the standard useSessions hook -// (subscription is the framework's, derivation is a pure function — design -// §6) plus a free-form new-directory input; submit runs the startSession -// chain (create → open → send) in one service call. +// EmptyState (figma NEW SESSION screen): centered hero — fish + title, +// workspace picker row, then the SAME InputBar the resident composer uses +// (empty→content is a position move, never a swap). Project picker: cwd set +// derived in-component from useSessions plus a free-form new-directory path; +// submit runs startSession (create → open → send). -import { useMemo, useState } from 'react' -import { FishLogo } from '@deepseek-ai/dsh-client-ui-primitives' +import { useId, useMemo, useState } from 'react' +import { + FishLogo, + IconChevronDownOutline14, + IconFolderOpen16, + Menu, + type MenuItem, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import type { EmptyStateSlotProps } from '../contract/slots.ts' import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' import css from './EmptyState.module.css' -/** Select sentinel for the free-form directory entry (impossible as a real path: not absolute). */ +/** Menu id for the free-form directory entry (not a filesystem path). */ const NEW_DIR = '::new-directory' +/** Menu id for the host default project directory (empty cwd on create). */ +const DEFAULT_DIR = '::default' /** Full props composed by reference from the contract (runtime share & injected share; no store). */ export type EmptyStateProps = EmptyStateSlotProps @@ -30,16 +36,26 @@ function deriveCwds(state: SessionListState): readonly string[] { return [...seen] } +/** Basename for the workspace chip; empty → the design's "New Workspace" label. */ +function workspaceLabel(cwd: string): string { + if (cwd === '') return 'New Workspace' + const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() + return base !== undefined && base !== '' ? base : cwd +} + export function EmptyState({ useSessions, startSession }: EmptyStateProps) { const list = useSessions(s => s) const cwds = useMemo(() => deriveCwds(list), [list]) // Local viewing state: the empty state owns no session, so its draft is // ephemeral by design (drafts are keyed by session id; there is none yet). const [draft, setDraft] = useState('') - const [cwd, setCwd] = useState('') + const [cwd, setCwd] = useState('') const [custom, setCustom] = useState(false) + const [menuOpen, setMenuOpen] = useState(false) const [sending, setSending] = useState(false) const [error, setError] = useState(null) + // Stable filter id so multiple EmptyState mounts do not collide in the DOM. + const glowFilterId = `empty-glow-${useId().replace(/:/g, '')}` const submit = (mode: 'queue' | 'steer'): void => { const text = draft.trim() @@ -58,61 +74,105 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { // Success needs no cleanup: the session selection swaps this slot out for the session body. } - const picker = ( -
- {custom - ? ( - { setCwd(e.target.value) }} - /> - ) - : ( - { setCwd(e.target.value) }} + /> + ) + : ( + { setMenuOpen(false) }} + selectedId={selectedId} + items={items} + onSelect={(id) => { + if (id === NEW_DIR) { + setCustom(true) + setCwd('') + } else if (id === DEFAULT_DIR) { + setCustom(false) + setCwd('') + } else { + setCustom(false) + setCwd(id) + } + setMenuOpen(false) + }} + anchor={( + )} -
- ) + /> + ) return (
-
+
- {/* figma 34:10412: fish 34x25 leading the headline, gap 10. */} + {/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} Let's start building
- {}} - /> +
+ {/* figma 313:14109: soft ellipse behind workspace + InputBar; width + tracks the card (1051/776) so blur scales in userSpace with it. */} + +
{workspace}
+ {}} + /> +
) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 1139c3c9c1..04e4f661c5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -119,12 +119,81 @@ min-height: 84px; } -/* figma Frame 1123 (34:11463): pad 12/0/10/10, buttons vertically centered. */ +/* Toolbar: attach + Plan + Read-only on the left; model + send on the right + (figma Input_Bottom chrome). */ .row { display: flex; align-items: center; - justify-content: flex-end; - padding: 0 10px 10px 12px; + justify-content: space-between; + gap: 12px; + padding: 0 10px 10px 10px; + min-width: 0; +} + +.tools, +.trailing { + display: flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.trailing { + flex: none; + gap: 8px; +} + +/* Attach circle (figma + control): 28px, selector fill, primary glyph. */ +.add { + display: grid; + place-items: center; + flex: none; + width: 28px; + height: 28px; + border: none; + border-radius: 999px; + background: var(--dsw-specific-selector); + color: var(--dsw-alias-label-primary); + cursor: pointer; +} + +.add:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-solid); +} + +.add:disabled { + opacity: 0.5; + cursor: default; +} + +/* Plan / Read-only / model — native state, no host wiring. -import { useEffect, useRef } from 'react' -import type { KeyboardEvent, MouseEvent, ReactNode } from 'react' +import { useEffect, useRef, useState } from 'react' +import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' +import { IconPlusOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import css from './InputBar.module.css' /** Prompt failure surface (mirrors the session snapshot's promptError shape). */ @@ -24,13 +28,33 @@ export interface InputBarProps { /** Hero = empty-state centered card; composer = resident bottom bar. */ variant: 'hero' | 'composer' placeholder?: string - /** Optional leading accessory row content (the empty state mounts its cwd picker here). */ + /** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */ accessory?: ReactNode onDraftChange: (text: string) => void onSend: (mode: 'queue' | 'steer') => void onStop: () => void } +interface SelectOption { + id: string + label: string +} + +const PLAN_OPTIONS: readonly SelectOption[] = [ + { id: 'plan', label: 'Plan' }, + { id: 'agent', label: 'Agent' }, +] + +const READONLY_OPTIONS: readonly SelectOption[] = [ + { id: 'readonly', label: 'Read-only' }, + { id: 'readwrite', label: 'Read-write' }, +] + +const MODEL_OPTIONS: readonly SelectOption[] = [ + { id: 'v4-pro-high', label: 'DeepSeek-V4-Pro High' }, + { id: 'v4-pro', label: 'DeepSeek-V4-Pro' }, +] + export function InputBar({ draft, running, disabled, error, variant, placeholder, accessory, onDraftChange, onSend, onStop, }: InputBarProps) { @@ -48,6 +72,11 @@ export function InputBar({ }, 10) } + // Placeholder chrome: selection is local until plan/mode/model seams land. + const [planId, setPlanId] = useState('plan') + const [readonlyId, setReadonlyId] = useState('readonly') + const [modelId, setModelId] = useState('v4-pro-high') + // Locked while running: the browser drops keystrokes AND focus on a disabled // textarea — no sending mid-turn, stop or wait. const locked = disabled || running @@ -88,6 +117,25 @@ export function InputBar({ if (!empty && !disabled) onSend('queue') } + const renderSelect = ( + aria: string, + value: string, + options: readonly SelectOption[], + onPick: (id: string) => void, + ): ReactNode => ( + + ) + return (
{error !== null && ( @@ -116,25 +164,42 @@ export function InputBar({
{`${draft}\n`}
- +
+ + {renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} + {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} +
+
+ {renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)} + +
diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 660127946b..6bf58f7d59 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -19,7 +19,10 @@ function setup(over?: Partial) { } const view = render() const textarea = view.container.querySelector('textarea')! - const button = view.container.querySelector('button')! + // aria-label (not role name): title also contains 发送/停止 and would double-match. + const button = view.container.querySelector( + `button[aria-label="${over?.running === true ? '停止' : '发送'}"]`, + )! return { view, textarea, button, props } } @@ -97,7 +100,7 @@ describe('running lock and primary button', () => { const textarea = view.container.querySelector('textarea')! expect(document.activeElement).toBe(textarea) textarea.blur() - fireEvent.mouseDown(view.container.querySelector('button')!) + fireEvent.mouseDown(view.container.querySelector('button[aria-label="发送"]')!) expect(document.activeElement).toBe(textarea) }) @@ -129,3 +132,38 @@ describe('error strip and variants', () => { expect(view.container.querySelector('[class*="hero"]')).not.toBeNull() }) }) + +describe('placeholder chrome', () => { + it('renders attach / Plan / Read-only / model controls', () => { + const { view } = setup() + expect(view.getByLabelText('添加')).toBeTruthy() + expect((view.getByLabelText('Plan mode') as HTMLSelectElement).value).toBe('plan') + expect((view.getByLabelText('Access mode') as HTMLSelectElement).value).toBe('readonly') + expect((view.getByLabelText('Model') as HTMLSelectElement).value).toBe('v4-pro-high') + }) + + it('native select change updates the selected option', () => { + const { view } = setup() + const plan = view.getByLabelText('Plan mode') as HTMLSelectElement + fireEvent.change(plan, { target: { value: 'agent' } }) + expect(plan.value).toBe('agent') + const access = view.getByLabelText('Access mode') as HTMLSelectElement + fireEvent.change(access, { target: { value: 'readwrite' } }) + expect(access.value).toBe('readwrite') + }) + + it('model select can drop the High option', () => { + const { view } = setup() + const model = view.getByLabelText('Model') as HTMLSelectElement + fireEvent.change(model, { target: { value: 'v4-pro' } }) + expect(model.value).toBe('v4-pro') + expect(model.selectedOptions[0]?.textContent).toBe('DeepSeek-V4-Pro') + }) + + it('running locks the chrome selects and attach control', () => { + const { view } = setup({ running: true }) + expect((view.getByLabelText('添加') as HTMLButtonElement).disabled).toBe(true) + expect((view.getByLabelText('Plan mode') as HTMLSelectElement).disabled).toBe(true) + expect((view.getByLabelText('Model') as HTMLSelectElement).disabled).toBe(true) + }) +}) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index 10dba860c0..b93b9c0b72 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -261,7 +261,7 @@ describe('EmptyState branches', () => { await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy()) }) - it('cwd derivation skips blank cwds; select picks, swaps to free-form, submits the typed path', async () => { + it('cwd derivation skips blank cwds; menu picks, swaps to free-form, submits the typed path', async () => { const startSession = vi.fn(() => Promise.resolve()) const view = render( { startSession={startSession} />, ) - const select = view.container.querySelector('select')! - expect([...(select as HTMLSelectElement).options].map(o => o.value)) - .toEqual(['', '/proj', '::new-directory']) - fireEvent.change(select, { target: { value: '/proj' } }) - expect((select as HTMLSelectElement).value).toBe('/proj') - fireEvent.change(select, { target: { value: '::new-directory' } }) + fireEvent.click(view.getByRole('button', { name: '项目目录' })) + expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) + .toEqual(['Default directory', '/proj', 'New directory…']) + fireEvent.click(view.getByRole('menuitem', { name: '/proj' })) + expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj') + fireEvent.click(view.getByRole('button', { name: '项目目录' })) + fireEvent.click(view.getByRole('menuitem', { name: 'New directory…' })) const custom = view.container.querySelector('input')! fireEvent.change(custom, { target: { value: '/typed/dir' } }) const textarea = view.container.querySelector('textarea')! diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 55cb46be2c..b2636bfd4b 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -28,7 +28,8 @@ const sid = (s: string): SessionId => s as SessionId afterEach(cleanup) beforeEach(() => { - localStorage.clear() + // jsdom normally provides localStorage; some host Node builds surface it as undefined. + globalThis.localStorage?.clear() }) /** Minimal conversation snapshot slice the skeleton reads. */ @@ -95,11 +96,13 @@ describe('EmptyState', () => { const startSession = vi.fn(() => new Promise((_res, rej) => { reject = rej })) render() - const select = screen.getByRole('combobox', { name: '项目目录' }) - expect([...(select as HTMLSelectElement).options].map(o => o.value)) - .toEqual(['', '/w/app', '/w/lib', '::new-directory']) - fireEvent.change(select, { target: { value: '/w/app' } }) - const box = screen.getByPlaceholderText('Message to run task, plan and build') + const trigger = screen.getByRole('button', { name: '项目目录' }) + fireEvent.click(trigger) + const menu = screen.getByRole('menu') + expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) + .toEqual(['Default directory', '/w/app', '/w/lib', 'New directory…']) + fireEvent.click(screen.getByRole('menuitem', { name: '/w/app' })) + const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands') fireEvent.change(box, { target: { value: '造一个轮子' } }) fireEvent.keyDown(box, { key: 'Enter' }) expect(startSession).toHaveBeenCalledWith({ text: '造一个轮子', mode: 'queue', cwd: '/w/app' }) @@ -110,11 +113,12 @@ describe('EmptyState', () => { expect((box as HTMLTextAreaElement).value).toBe('造一个轮子') }) - it('new-directory option swaps the select for a free-form input', () => { + it('new-directory option swaps the chip for a free-form input', () => { const { useSessions } = fakeSessions([]) render( Promise.resolve()} />) - fireEvent.change(screen.getByRole('combobox'), { target: { value: '::new-directory' } }) - const custom = screen.getByPlaceholderText(/目录路径/) + fireEvent.click(screen.getByRole('button', { name: '项目目录' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'New directory…' })) + const custom = screen.getByPlaceholderText(/Directory path/) fireEvent.change(custom, { target: { value: '/tmp/fresh' } }) expect((custom as HTMLInputElement).value).toBe('/tmp/fresh') }) diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 7529bfe89c..c165455b1a 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-sidebar -Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). +Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Top-level New Session / New Workspace clear the selection onto `conversation.empty`; per-project "+" still create-then-opens. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). `src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx. diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 3012a760af..a5ce65ef59 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -25,8 +25,8 @@ export type SidebarRootInjected = { /** Open (switch to) a session. */ onOpen: (id: SessionId) => void /** - * Create a session and open it; cwd targets a project group (the - * sidebar's three creation entries all land in the new session). + * New-session affordance: no cwd clears selection onto the empty-state + * launch; a cwd create-then-opens a session in that project group. */ onCreate: (cwd?: string) => void /** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */ diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index fe799a864f..be757ca183 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -27,9 +27,15 @@ export function apply(ctx: ClientContext): void { // list snapshot); layout keeps only panel geometry. onOpen: (id) => { ctx.sessions.open(id) }, onCreate: (cwd) => { - // Create-then-open: the sidebar's three creation entries all land - // in the new session (empty-state first-send stays with ui-conversation). - void ctx.sessions.create(cwd === undefined ? {} : { cwd }) + // Top-level New Session / New Workspace: clear selection so AppFrame + // shows conversation.empty (EmptyState + shared InputBar). Per-project + // "+" still create-then-opens into that cwd until workspace seeding + // reaches the empty-state picker. + if (cwd === undefined) { + ctx.sessions.clear() + return + } + void ctx.sessions.create({ cwd }) .then((id: SessionId) => { ctx.sessions.open(id) }) }, onToggleSidebar: () => { ctx.layout.toggleSidebar() }, diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index 6b6b4f9474..44fdae18f8 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -26,7 +26,12 @@ async function bench() { byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } }, current: undefined, }) - const sessions = { list, create: vi.fn(async () => sid('minted')), open: vi.fn() } + const sessions = { + list, + create: vi.fn(async () => sid('minted')), + open: vi.fn(), + clear: vi.fn(), + } const layout = { toggleSidebar: vi.fn() } ctx.provide('sessions', sessions) ctx.provide('layout', layout) @@ -91,14 +96,15 @@ describe('apply', () => { expect(sessions.open).toHaveBeenCalledWith('a') injected.onCreate() - expect(sessions.create).toHaveBeenCalledWith({}) + expect(sessions.clear).toHaveBeenCalledOnce() + expect(sessions.create).not.toHaveBeenCalled() + + injected.onCreate('/proj') + expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' }) // create-then-open lands after the create promise resolves. await Promise.resolve() await Promise.resolve() expect(sessions.open).toHaveBeenCalledWith('minted') - - injected.onCreate('/proj') - expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' }) }) it('teardown unregisters the slot entry', async () => { From 06143b1a86fcee75d3f2887717f908400e2e6375 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 14:36:44 +0800 Subject: [PATCH 07/15] fix: cr --- .../2026-07-23-trajectory-step-cell.i18n.yaml | 4 +- .../2026-07-23-trajectory-step-cell.md | 4 +- .../2026-07-23-trajectory-step-cell.zh.md | 4 +- .../client/ui-trajectory/src/client/layout.ts | 66 +++++++++++++++---- .../ui-trajectory/tests/layout.spec.tsx | 63 ++++++++++++++++++ 5 files changed, 124 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml index fb39ae1301..1702c90c43 100644 --- a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-trajectory-step-cell.md: edf31dcc72baa980caf0aa90fb8d5ec53d44346d -2026-07-23-trajectory-step-cell.zh.md: dbe813d48c3f3ac1c0926e45137624d758109c55 +2026-07-23-trajectory-step-cell.md: 414c3aac856fb5e60f0e4cf42f8e7b410cdf3413 +2026-07-23-trajectory-step-cell.zh.md: aa76b422f165ebf6918b3781fdfe38797a34ba51 diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md index 42d896b81a..414c3aac85 100644 --- a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.md @@ -14,9 +14,9 @@ The trajectory tab needs a reusable step row and turn-list chrome that can show - [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 38px step row with kinds User / Message / Tool (no Think, Call, or Result rows). Reasoning blocks are skipped (no block-level clock). Each `tool-call` + paired `tool-result` folds into one Tool row (`name ·` truncated args) whose Time is `result.time − callTime` when both are known. Message rows carry Input/Output/Think token columns from `assistant.usage`. Own-duration Time uses `+Ns` / `+N.1s`, or `—` when absent. Selected state draws a 2px inset `--dsw-alias-brand-primary-new-colorprimary-new-color` ring (`selected` prop) and is not wired to chat selection. - [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — sticky Turn bar paints full-bleed `ghost-active-fill`; title/columns and the Message/Step body sit in a centered `max-width: 880px` lane. Cell trailing columns share the Turn header geometry (`320 = 4×71 + 3×12`); cells use pad 20/8. -- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only, and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) expands assistant `blocks[]` into cells, pairs tool-calls with `tool-result` by `callId` into Tool, folds `partial` and `runningCalls` (deduped), hangs usage on Message only (including the empty fallback when there is no text block), and builds group descriptions as wall-span + tool histogram (`1.5s bash×6`). `user/message` has no wire turn, so each User row is enclosed in the next assistant/steering turn, else the in-flight `partial` turn, else `lastAssistantTurn + 1` (or `1`). Context nodes emit no cell but still advance the Message duration cursor. -[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time; Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration). +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) carries `time` from `SessionEvent.time`; `ToolResultNode.callTime` and `RunningToolCall.time` come from the paired `tool/call`. Duration rules: User `+0s`; Message = assistant.time − previous surface time (including skipped context); Tool = result.time − callTime when both known; in-flight Tool = `—`. Group header duration is earliest→latest absolute time in the group (wall span; Tool contributes start and start+duration). ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md index c6bcde7deb..aa76b422f1 100644 --- a/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md +++ b/.agents/notes/implemented/feature/2026-07-23-trajectory-step-cell.zh.md @@ -14,9 +14,9 @@ trajectory 标签页需要可复用的步骤行与轮次列表 chrome,以展 - [`TrajectoryCell`](../../../../packages/client/ui-trajectory/src/client/TrajectoryCell.tsx) — 高 38px 的步骤行,类型为 User / Message / Tool(无 Think、Call、Result 行)。reasoning 块跳过(无块级时钟)。每对 `tool-call` + `tool-result` 折成一行 Tool(`name ·` 加截断参数),Time 在两端皆知时为 `result.time − callTime`。Message 行携带来自 `assistant.usage` 的 Input/Output/Think token 列。自身耗时 Time 使用 `+Ns` / `+N.1s`,缺失时为 `—`。选中态绘制 2px 内嵌的 `--dsw-alias-brand-primary-new-colorprimary-new-color` 环(`selected` prop),且未接线到 chat 选中。 - [`TrajectoryTurn`](../../../../packages/client/ui-trajectory/src/client/TrajectoryTurn.tsx) / header / group header — 粘性 Turn 条背景通栏铺 `ghost-active-fill`;标题/列标与 Message/Step 主体落在居中的 `max-width: 880px` 内容道。单元格右侧列与 Turn 标头共用几何(`320 = 4×71 + 3×12`);cell pad 20/8。 -- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上,并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。 +- [`deriveTrajectoryLayout`](../../../../packages/client/ui-trajectory/src/client/layout.ts) 将 assistant `blocks[]` 展开为单元格,按 `callId` 将 tool-call 与 tool-result 配对为 Tool,折叠 `partial` 与 `runningCalls`(去重),仅将用量挂在 Message 上(含无 text 块时的空回退行),并以墙钟跨度 + 工具直方图构建分组描述(`1.5s bash×6`)。`user/message` 无线上 turn,故每条 User 行归入下一 assistant/steering 的 turn,否则归入进行中的 `partial` turn,否则为 `lastAssistantTurn + 1`(或 `1`)。context 节点不产出单元格,但仍推进 Message 耗时游标。 -[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间;Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。 +[`ConversationNode`](../../../../packages/client/runtime/src/client/sessions/conversation.ts) 携带来自 `SessionEvent.time` 的 `time`;`ToolResultNode.callTime` 与 `RunningToolCall.time` 来自配对的 `tool/call`。耗时规则:User 为 `+0s`;Message = assistant.time − 上一表面时间(含跳过的 context);Tool = 在两者皆知时 result.time − callTime;进行中 Tool = `—`。分组标头耗时为组内最早→最晚绝对时间(墙钟跨度;Tool 贡献起点与起点+自身耗时)。 ## Alternatives considered diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 03bfcb6893..e188498554 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -54,6 +54,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const turns = new Map }>() let index = 0 let prevAbsTime: number | null = null + let lastAssistantTurn: number | null = null const bucket = (turn: number) => { let entry = turns.get(turn) @@ -74,9 +75,16 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T steps.set(step, list) } - for (const node of nodes) { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + /* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */ + if (node === undefined) continue if (node.kind === 'user' || node.kind === 'steering') { - const turn = node.kind === 'steering' ? node.turn : 0 + // user/message has no turn on the wire; enclose it in the next assistant + // (or partial) turn, else open the turn after the last assistant. + const turn = node.kind === 'steering' + ? node.turn + : enclosingUserTurn(nodes, i, partial, lastAssistantTurn) pushMessage(turn, { absTime: finiteTime(node.time), cell: { @@ -96,6 +104,12 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T const last = laidList[laidList.length - 1] if (last !== undefined) index = last.cell.index prevAbsTime = finiteTime(node.time) ?? prevAbsTime + lastAssistantTurn = node.turn + continue + } + if (node.kind === 'context') { + // No trajectory cell, but the surface still advances the duration cursor. + prevAbsTime = finiteTime(node.time) ?? prevAbsTime continue } if (node.kind === 'tool-result') { @@ -149,6 +163,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T }) } + // Orphan turn-0 cells (orphaned tools / steering turn 0) fold into Turn 1. const prologue = turns.get(0) if (prologue !== undefined) { turns.delete(0) @@ -269,11 +284,9 @@ function expandAssistant( index: ++index, kind: 'message', text: summarizeText(block.text), timeSeconds: messageDuration, } - if (!usageAttached && usage !== undefined) { - if (usage.inputTokens !== undefined) cell.input = usage.inputTokens - if (usage.outputTokens !== undefined) cell.output = usage.outputTokens - if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens - usageAttached = true + if (!usageAttached) { + attachUsage(cell, usage) + usageAttached = usage !== undefined } out.push({ absTime: nodeAbs, cell }) continue @@ -302,14 +315,45 @@ function expandAssistant( } if (out.length === 0 && !streaming) { - out.push({ - absTime: nodeAbs, - cell: { index: ++index, kind: 'message', text: '', timeSeconds: messageDuration }, - }) + // Reasoning-only / empty success still owns provider usage on the Message row. + const cell: TrajectoryCellProps = { + index: ++index, kind: 'message', text: '', timeSeconds: messageDuration, + } + attachUsage(cell, usage) + out.push({ absTime: nodeAbs, cell }) } return out } +/** + * Turn that encloses a user/message: next assistant/steering turn, else the + * in-flight partial, else the turn after the last finalized assistant (or 1). + */ +function enclosingUserTurn( + nodes: ConversationSnapshot['nodes'], + userIndex: number, + partial: ConversationSnapshot['partial'], + lastAssistantTurn: number | null, +): number { + for (let i = userIndex + 1; i < nodes.length; i++) { + const n = nodes[i] + /* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */ + if (n === undefined) continue + if (n.kind === 'assistant' || n.kind === 'steering') return n.turn + } + if (partial !== null) return partial.turn + if (lastAssistantTurn !== null) return lastAssistantTurn + 1 + return 1 +} + +/** Copy provider usage onto a Message cell when present. */ +function attachUsage(cell: TrajectoryCellProps, usage: UsageLike | undefined): void { + if (usage === undefined) return + if (usage.inputTokens !== undefined) cell.input = usage.inputTokens + if (usage.outputTokens !== undefined) cell.output = usage.outputTokens + if (usage.reasoningTokens !== undefined) cell.think = usage.reasoningTokens +} + function indexResults(nodes: ConversationSnapshot['nodes']): Map { const map = new Map() for (const node of nodes) { diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index 4dc395221d..9773f6fe57 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -140,4 +140,67 @@ describe('deriveTrajectoryLayout', () => { const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns[0]?.groups[0]?.description).toBe('2.9s bash×2') }) + + it('assigns each user message to its enclosing turn instead of pooling into Turn 1', () => { + const nodes = [ + { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'first' }], source: null }, + { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 0, + blocks: [{ kind: 'text', text: 'ok1' }], + }, + { kind: 'user', seq: 3, time: 3_000, content: [{ type: 'text', text: 'second' }], source: null }, + { + kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 0, + blocks: [{ kind: 'text', text: 'ok2' }], + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + expect(turns.map((t) => t.turn)).toEqual([1, 2]) + expect(turns[0]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['first', 'ok1']) + expect(turns[1]?.groups.flatMap((g) => g.cells.map((c) => c.text))).toEqual(['second', 'ok2']) + }) + + it('keeps usage on the fallback Message row when assistant has no text block', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0, + blocks: [{ kind: 'reasoning', text: '…' }], + usage: { inputTokens: 11, outputTokens: 22, reasoningTokens: 3 }, + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const message = turns[0]?.groups.flatMap((g) => g.cells).find((c) => c.kind === 'message') + expect(message).toMatchObject({ + text: '', input: 11, output: 22, think: 3, + }) + }) + + it('advances the duration cursor over context nodes', () => { + const nodes = [ + { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null }, + { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, + blocks: [{ kind: 'tool-call', callId: 'c1', name: 'bash', argsRaw: '{}' }], + }, + { + kind: 'tool-result', seq: 3, time: 3_000, callId: 'c1', + call: { name: 'bash', argsRaw: '{}' }, callTime: 2_100, + content: [], isError: false, callView: null, resultView: null, + }, + { + kind: 'context', seq: 4, time: 9_000, + content: [{ type: 'text', text: 'extra' }], source: null, + }, + { + kind: 'assistant', seq: 5, time: 10_000, turn: 1, step: 0, + blocks: [{ kind: 'text', text: 'done' }], + }, + ] as unknown as ConversationSnapshot['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const message = turns[0]?.groups + .flatMap((g) => g.cells) + .find((c) => c.kind === 'message' && c.text === 'done') + // From context at 9s, not from the earlier user/tool surfaces. + expect(message?.timeSeconds).toBe(1) + }) }) From 6f5321cb37d7f456381533c15c3ec1ba2046e8ef Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 16:09:58 +0800 Subject: [PATCH 08/15] feat: workspace select menu --- ...ew-session-clears-to-empty-state.i18n.yaml | 4 +- ...07-24-new-session-clears-to-empty-state.md | 2 +- ...24-new-session-clears-to-empty-state.zh.md | 2 +- .../runtime/src/client/sessions/service.ts | 23 +- .../runtime/tests/sessions-service.spec.ts | 21 ++ .../ui-conversation/src/client/apply.ts | 4 + .../src/client/contract/slots.ts | 5 + .../src/client/skeleton/EmptyState.module.css | 87 ++++-- .../src/client/skeleton/EmptyState.tsx | 256 +++++++++++++----- .../src/client/skeleton/InputBar.module.css | 58 ++-- .../src/client/skeleton/InputBar.tsx | 10 +- .../tests/apply-inject.spec.tsx | 9 +- .../tests/skeleton-branches.spec.tsx | 38 ++- .../ui-conversation/tests/skeleton.spec.tsx | 76 +++++- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/package.json | 2 +- .../ui-primitives/src/Button.module.css | 14 + packages/client/ui-primitives/src/Button.tsx | 2 +- .../client/ui-primitives/src/Menu.module.css | 81 +++++- packages/client/ui-primitives/src/Menu.tsx | 109 ++++++-- .../client/ui-primitives/src/Modal.module.css | 79 ++++++ packages/client/ui-primitives/src/Modal.tsx | 62 +++++ packages/client/ui-primitives/src/index.ts | 5 +- .../client/ui-primitives/tests/atoms.spec.tsx | 87 +++++- packages/host/runtime/src/api-proxy.ts | 14 +- .../host/runtime/tests/host-runtime.spec.ts | 25 +- 26 files changed, 890 insertions(+), 187 deletions(-) create mode 100644 packages/client/ui-primitives/src/Modal.module.css create mode 100644 packages/client/ui-primitives/src/Modal.tsx diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml index 5f41ac4b70..4b7354a322 100644 --- a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-new-session-clears-to-empty-state.md: d730f3e0b658ea66b6026593f37a97893e32a4db -2026-07-24-new-session-clears-to-empty-state.zh.md: 602a2b774b569cfef0adcc253fe751f86b14f895 +2026-07-24-new-session-clears-to-empty-state.md: 1605f44a05d0f59b61fe95cb5b03a0f9f5c3d4ab +2026-07-24-new-session-clears-to-empty-state.zh.md: 1f78d99babc33d30ee1300bfa6bf048a78e7132e diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md index d730f3e0b6..1605f44a05 100644 --- a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.md @@ -20,4 +20,4 @@ Sidebar "New Session" created and opened a blank session immediately, so the cen ## Consequences -New Session no longer mints a host session until the first send. Reloading after clear stays on the empty state. Project-scoped "+" still creates immediately. `EmptyState` stacks the Figma hero as fish + title, a Menu-backed workspace chip ("New Workspace" / basename / free-form path) above the card, then shared `InputBar` (`variant="hero"`), with a soft ellipse glow (figma 313:14109) centered behind the picker + card and width-locked to the card (`1051/776`) so it scales with it. `InputBar` paints the bottom chrome (attach / Plan / Read-only / model) with local native `` state only — host plan, access, and model seams remain unwired. diff --git a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md index 602a2b774b..1f78d99bab 100644 --- a/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-new-session-clears-to-empty-state.zh.md @@ -20,4 +20,4 @@ Status: implemented ## Consequences -New Session 在首次发送前不再创建 host 会话。clear 后重新加载仍停留在空态。项目范围的「+」仍立即创建。`EmptyState` 按 Figma 堆叠英雄区:鱼标 + 标题、卡片上方的 Menu 工作区 chip(「New Workspace」/ 路径 basename / 自由输入路径),再接共用的 `InputBar`(`variant="hero"`);选择器与卡片背后居中铺一层柔光椭圆(figma 313:14109),宽度按卡片锁定为 `1051/776`,随卡片缩放。`InputBar` 绘制底栏 chrome(添加 / Plan / Read-only / 模型),仅用本地原生 `` 状态——host 侧的 plan、access、model 接缝仍未接线。 diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 324c574474..d8a6f05762 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -117,7 +117,7 @@ export class SessionsService { * @param ctx - client root context (scope fibers mount under it). * @param api - wire client shared with every Session. */ - constructor(private readonly rootCtx: Context, api: IApiClient) { + constructor(private readonly rootCtx: Context, private readonly api: IApiClient) { this.manager = new SessionManager(api) this.selection = createSnapshotStore<{ sessionId?: SessionId }>( {}, @@ -171,6 +171,27 @@ export class SessionsService { return result.value.sessionId } + /** + * Create a workspace folder under the host process cwd and a session in it. + * Name is a single path segment (no separators); the host mkdir runs inside + * session.create. Caller opens the returned id when it wants the session staged. + * @param name - workspace folder basename. + * @returns the new session id. + */ + async createWorkspace(name: string): Promise { + const trimmed = name.trim() + if (trimmed === '') throw new Error('sessions.createWorkspace: name is required') + if (/[/\\]/.test(trimmed)) { + throw new Error('sessions.createWorkspace: name must not contain path separators') + } + const { result } = await this.api.host.describe({}) + if (!result.ok) { + throw new Error(`host.describe failed: ${result.error.code}: ${result.error.message}`) + } + const hostCwd = result.value.cwd.replace(/[/\\]+$/, '') + return this.create({ cwd: `${hostCwd}/${trimmed}` }) + } + /** * Resolve a session-scoped context view (use-and-discard). * @param id - session id. diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 33cfce43bb..8c850426bd 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -297,6 +297,27 @@ describe('create', () => { }) }) +describe('createWorkspace', () => { + it('joins host.describe cwd with the name and creates there', async () => { + const b = bench() + b.api.onDescribe = () => Promise.resolve(ok({ version: '0', cwd: '/host/root', attachedSessions: 0 })) + b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('ws') })) + await expect(b.svc.createWorkspace('My Proj')).resolves.toBe('ws') + expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/host/root/My Proj' }]) + }) + + it('rejects empty names and path separators; surfaces describe failures', async () => { + const b = bench() + await expect(b.svc.createWorkspace(' ')).rejects.toThrow(/name is required/) + await expect(b.svc.createWorkspace('a/b')).rejects.toThrow(/path separators/) + b.api.onDescribe = () => Promise.resolve({ + rpcId: 'e' as never, + result: { ok: false as const, error: { code: 'internal' as const, message: 'down', details: {} } }, + } as never) + await expect(b.svc.createWorkspace('ok')).rejects.toThrow(/host.describe failed/) + }) +}) + describe('coverage tails (branch duals)', () => { it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => { const b = bench() diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 8c4a6dc6a1..372eb36c80 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -160,6 +160,10 @@ export function apply(ctx: Context): void { if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable') return conversation.startSession(opts) }, + createWorkspaceSession: async (name) => { + const id = await sessions.createWorkspace(name) + sessions.open(id) + }, }), }, EmptyState) } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index baa26683ec..ffbc13ff59 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -164,6 +164,11 @@ export type DetailsSlotProps = PropsRuntime<'details'> & PropsStore & export interface EmptyStateInjected { /** The create → navigate → first-send chain, in one service call. */ startSession(opts: { cwd?: string; text: string; mode: 'queue' | 'steer' }): Promise + /** + * Create a workspace folder under the host cwd, mint a session there, and + * open it (Create-new modal success path). + */ + createWorkspaceSession(name: string): Promise } /** Full empty-state component props (root slot: no store; cwd options derive from useSessions in-component). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css index 53e618b122..bc5d9d62d0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.module.css @@ -1,6 +1,6 @@ -/* NEW SESSION hero (figma 313:14149): fish + title, workspace chip above the - shared InputBar card. The input itself is InputBar — only stack geometry - lives here. */ +/* NEW SESSION hero (figma Input_Bottom 75:8208): fish + title, workspace chip + above the shared InputBar card. The input itself is InputBar — only stack + geometry and the chip live here. */ .root { display: flex; @@ -11,23 +11,26 @@ padding: 24px; } -/* Cap matches InputBar card width (776). Glow may paint past the sides. */ +/* Cap matches InputBar card width (800). Glow may paint past the sides. */ .stack { display: flex; flex-direction: column; align-items: stretch; - gap: 40px; + /* figma 75:8208: 12 between title block / workspace / card. */ + gap: 12px; width: 100%; - max-width: 776px; + max-width: 800px; overflow: visible; } -/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600. */ +/* figma 34:10411: fish + title, gap 10, centered; 26/32 wt600; title block + keeps 36px below the headline before the flex gap. */ .headline { display: flex; align-items: center; justify-content: center; gap: 10px; + padding-bottom: 36px; font-size: 26px; line-height: 32px; font-weight: 600; @@ -68,38 +71,43 @@ z-index: 1; } -.workspaceRow { +/* Must beat `.body > :not(.glow)` specificity so the open Menu (and its + right-hand submenu) paints above the InputBar card. */ +.body > .workspaceRow { + z-index: 10; display: flex; align-items: center; min-width: 0; - /* Align with InputBar's left chrome (card pad 10 + attach). */ - padding-left: 10px; + /* figma 75:8208 workspace row: px 8 above the card. */ + padding-left: 8px; } -/* Folder + "New Workspace" + chevron (figma workspace trigger). */ +/* Folder + label + chevron — transparent at rest; fill only on hover / open. */ .workspace { display: inline-flex; align-items: center; - gap: 6px; + gap: 4px; max-width: 100%; - height: 28px; - padding: 0 4px 0 0; + min-height: 28px; + padding: 0 8px; border: none; - border-radius: 8px; + border-radius: 12px; background: transparent; color: var(--dsw-alias-label-primary); - font-size: 14px; + font-size: 13px; line-height: 20px; + font-weight: 500; cursor: pointer; } -.workspace:hover { +.workspace:hover, +.workspace[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); } .folder { flex: none; - color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-primary); } .workspaceLabel { @@ -113,19 +121,44 @@ color: var(--dsw-alias-label-caption); } -.customInput { - width: min(320px, 100%); - height: 28px; - padding: 0 10px; - border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); - border-radius: 8px; +/* Workspace menu width tracks the longest basename in the Figma frame. */ +.workspaceMenu :global([role='menu']) { + min-width: 240px; +} + +/* Dialog field (figma 451:18655 Input): h44, r22, px 14, caption placeholder. */ +.modalInput { + width: 100%; + height: 44px; + padding: 0 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 22px; outline: none; - background: var(--dsw-alias-bg-base); + background: transparent; font-size: 14px; - line-height: 20px; + line-height: 24px; color: var(--dsw-alias-label-primary); } -.customInput:focus { +.modalInput::placeholder { + color: var(--dsw-alias-label-caption); +} + +.modalInput:focus { border-color: var(--dsw-alias-state-business-primary); } + +.modalInput:disabled { + color: var(--dsw-alias-label-dimmed); +} + +.modalAction { + min-width: 72px; +} + +.modalError { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index edcf0cbad2..b112dfa432 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -1,16 +1,21 @@ // EmptyState (figma NEW SESSION screen): centered hero — fish + title, -// workspace picker row, then the SAME InputBar the resident composer uses -// (empty→content is a position move, never a swap). Project picker: cwd set -// derived in-component from useSessions plus a free-form new-directory path; -// submit runs startSession (create → open → send). +// workspace picker row (MenuDropdown 122:9481 + New Workspace submenu +// 419:16920 + Dialog 451:18655), then the SAME InputBar the resident +// composer uses (empty→content is a position move, never a swap). Project +// options derive in-component from useSessions; Create new runs +// createWorkspaceSession (host mkdir + session.create + open). import { useId, useMemo, useState } from 'react' import { + Button, FishLogo, IconChevronDownOutline14, + IconFolderClose16, IconFolderOpen16, + IconPlusOutline16, Menu, - type MenuItem, + Modal, + type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SessionListState } from '@deepseek-ai/dsh-client-runtime/client' import type { EmptyStateSlotProps } from '../contract/slots.ts' @@ -18,10 +23,15 @@ import { InputBar } from './InputBar.tsx' import type { InputBarError } from './InputBar.tsx' import css from './EmptyState.module.css' -/** Menu id for the free-form directory entry (not a filesystem path). */ -const NEW_DIR = '::new-directory' -/** Menu id for the host default project directory (empty cwd on create). */ -const DEFAULT_DIR = '::default' +/** Menu id for "New Workspace" (opens submenu; not a cwd). */ +const NEW_WORKSPACE = '::new-workspace' +/** Submenu: path modal (figma 451:18655 copy). */ +const USE_EXISTING = '::use-existing' +/** Submenu: create-workspace modal → mkdir + default session. */ +const CREATE_NEW = '::create-new' + +/** Which full-page dialog is open (null = none). */ +type ModalKind = 'path' | 'create' | null /** Full props composed by reference from the contract (runtime share & injected share; no store). */ export type EmptyStateProps = EmptyStateSlotProps @@ -36,22 +46,26 @@ function deriveCwds(state: SessionListState): readonly string[] { return [...seen] } -/** Basename for the workspace chip; empty → the design's "New Workspace" label. */ +/** Basename for the workspace chip / menu row; empty → the design's "New Workspace" label. */ function workspaceLabel(cwd: string): string { if (cwd === '') return 'New Workspace' const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop() return base !== undefined && base !== '' ? base : cwd } -export function EmptyState({ useSessions, startSession }: EmptyStateProps) { +export function EmptyState({ useSessions, startSession, createWorkspaceSession }: EmptyStateProps) { const list = useSessions(s => s) const cwds = useMemo(() => deriveCwds(list), [list]) // Local viewing state: the empty state owns no session, so its draft is // ephemeral by design (drafts are keyed by session id; there is none yet). const [draft, setDraft] = useState('') const [cwd, setCwd] = useState('') - const [custom, setCustom] = useState(false) const [menuOpen, setMenuOpen] = useState(false) + const [modalKind, setModalKind] = useState(null) + const [pathDraft, setPathDraft] = useState('') + const [workspaceName, setWorkspaceName] = useState('New WorkSpace') + const [creating, setCreating] = useState(false) + const [modalError, setModalError] = useState(null) const [sending, setSending] = useState(false) const [error, setError] = useState(null) // Stable filter id so multiple EmptyState mounts do not collide in the DOM. @@ -74,59 +88,64 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { // Success needs no cleanup: the session selection swaps this slot out for the session body. } - const items: MenuItem[] = [ - { id: DEFAULT_DIR, label: 'Default directory' }, - ...cwds.map(c => ({ id: c, label: c })), - { id: NEW_DIR, label: 'New directory…' }, + const items: MenuEntry[] = [ + ...cwds.map(c => ({ + id: c, + label: workspaceLabel(c), + icon: , + })), + ...(cwds.length > 0 ? [{ type: 'separator' as const, id: 'sep-new' }] : []), + { + id: NEW_WORKSPACE, + label: 'New Workspace', + icon: , + submenu: [ + { id: USE_EXISTING, label: 'Use a existing folder' }, + { id: CREATE_NEW, label: 'Create new' }, + ], + }, ] - const selectedId = custom ? NEW_DIR : cwd === '' ? DEFAULT_DIR : cwd - const workspace = custom - ? ( - { setCwd(e.target.value) }} - /> - ) - : ( - { setMenuOpen(false) }} - selectedId={selectedId} - items={items} - onSelect={(id) => { - if (id === NEW_DIR) { - setCustom(true) - setCwd('') - } else if (id === DEFAULT_DIR) { - setCustom(false) - setCwd('') - } else { - setCustom(false) - setCwd(id) - } - setMenuOpen(false) - }} - anchor={( - - )} - /> - ) + const closeModal = (): void => { + if (creating) return + setModalKind(null) + setModalError(null) + } + + const openPathModal = (): void => { + setPathDraft(cwd) + setModalError(null) + setModalKind('path') + } + + const openCreateModal = (): void => { + setWorkspaceName('New WorkSpace') + setModalError(null) + setModalKind('create') + } + + const confirmPath = (): void => { + const next = pathDraft.trim() + if (next === '') return + setCwd(next) + setModalKind(null) + } + + const confirmCreate = (): void => { + if (creating) return + setCreating(true) + setModalError(null) + createWorkspaceSession(workspaceName) + .catch((reason: unknown) => { + setModalError(reason instanceof Error ? reason.message : String(reason)) + setCreating(false) + }) + // Success swaps this slot out for the new session body — no local cleanup. + } + + const modalBusy = creating + const isPath = modalKind === 'path' + const isCreate = modalKind === 'create' return (
@@ -138,7 +157,8 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) {
{/* figma 313:14109: soft ellipse behind workspace + InputBar; width - tracks the card (1051/776) so blur scales in userSpace with it. */} + tracks the card (glow asset 1051 vs design card 776) so blur + scales in userSpace with it. */} -
{workspace}
+
+ { setMenuOpen(false) }} + {...(cwd !== '' ? { selectedId: cwd } : {})} + items={items} + side="top" + className={css.workspaceMenu!} + onSelect={(id) => { + if (id === USE_EXISTING) { + setMenuOpen(false) + openPathModal() + return + } + if (id === CREATE_NEW) { + setMenuOpen(false) + openCreateModal() + return + } + setCwd(id) + setMenuOpen(false) + }} + anchor={( + + )} + /> +
+ + + + + )} + > + { setPathDraft(e.target.value) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmPath() + } + }} + /> + + + + + + )} + > + { setWorkspaceName(e.target.value) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmCreate() + } + }} + /> + {modalError !== null &&
{modalError}
} +
) } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 04e4f661c5..7161a31931 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -1,7 +1,7 @@ -/* Floating capsule input (figma Input_Bottom 34:11445): card floats above the +/* Floating capsule input (figma Input_Bottom 75:8208): card floats above the viewport bottom inside the centered message column; textarea on top, action row below, one primary circle button bottom-right. Input width rides the - column (776 is a cap, not a fixed size — layout rule: the box shrinks with + column (800 is a cap, not a fixed size — layout rule: the box shrinks with the center column keeping its padding). Hero variant = the same card centered in the empty state; the transition between the two is a position move of one component. */ @@ -10,8 +10,8 @@ display: flex; flex-direction: column; align-items: center; - /* figma Input_Bottom 34:11445: pad L32/R32/B12; the bottom gradient mask is - owned by the chat scroller. Top 8 hosts the error strip's breathing room. */ + /* figma Input_Bottom: pad L32/R32/B12; the bottom gradient mask is owned by + the chat scroller. Top 8 hosts the error strip's breathing room. */ padding: 8px 32px 12px; } @@ -21,7 +21,7 @@ .error { width: 100%; - max-width: 776px; + max-width: 800px; margin-bottom: 6px; padding: 4px 8px; border-radius: 8px; @@ -34,10 +34,12 @@ .card { display: flex; flex-direction: column; - /* figma Input 34:11458: 12px between the text area and the button row. */ + /* figma Input 75:8208: 12px between the text area and the button row; 10px + top pad on the card before .InputText. */ gap: 12px; width: 100%; - max-width: 776px; + max-width: 800px; + padding-top: 10px; /* Input stroke: black/0.10 light, white/0.06 dark (figma darkmode note says the input border is one notch weaker than buttons) — exactly the l2-darkmode-thin pair. Fill: the input surface token (elevated in dark). */ @@ -49,11 +51,6 @@ line-height: 24px; } -/* New-session state rounds up (figma: r24 and a taller box). */ -.hero .card { - border-radius: 24px; -} - .accessory { display: flex; align-items: center; @@ -85,7 +82,8 @@ .input, .mirror { - padding: 12px 16px 0; + /* figma .InputText 34:10434: pl 16 / pr 12 / pt 4. */ + padding: 4px 12px 0 16px; font-size: inherit; line-height: inherit; white-space: pre-wrap; @@ -108,17 +106,12 @@ .mirror { visibility: hidden; pointer-events: none; - /* 2-line floor: 2 × 24px line + 12px top padding; 14-line cap (336px). */ - min-height: 60px; + /* figma min-h 52 (= ~2 × 24 line + 4pt); 14-line cap (336px). */ + min-height: 52px; max-height: 336px; overflow: hidden; } -.hero .mirror { - /* New-session box is taller at rest (figma 118px input area). */ - min-height: 84px; -} - /* Toolbar: attach + Plan + Read-only on the left; model + send on the right (figma Input_Bottom chrome). */ .row { @@ -131,16 +124,25 @@ } .tools, +.modes, .trailing { display: flex; align-items: center; - gap: 4px; min-width: 0; } +/* figma 75:8208: 16 between + and the mode chips; 4 between Plan / Read-only. */ +.tools { + gap: 16px; +} + +.modes { + gap: 4px; +} + .trailing { flex: none; - gap: 8px; + gap: 12px; } /* Attach circle (figma + control): 28px, selector fill, primary glyph. */ @@ -166,22 +168,24 @@ cursor: default; } -/* Plan / Read-only / model — native , chip-like closed chrome + (figma ToggleButton: 13/20 medium secondary, 12px chevron). */ .select { max-width: 220px; height: 28px; - padding: 0 22px 0 6px; + padding: 0 20px 0 8px; border: none; border-radius: 8px; outline: none; background-color: transparent; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14' fill='none'%3E%3Cpath d='M3.5 5.25L7 8.75L10.5 5.25' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 4px center; - background-size: 14px 14px; + background-size: 12px 12px; color: var(--dsw-alias-label-secondary); - font-size: 14px; + font-size: 13px; line-height: 20px; + font-weight: 500; white-space: nowrap; cursor: pointer; appearance: none; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index f6454071b3..04d1dd867d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -175,8 +175,10 @@ export function InputBar({ > - {renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} - {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} +
+ {renderSelect('Plan mode', planId, PLAN_OPTIONS, setPlanId)} + {renderSelect('Access mode', readonlyId, READONLY_OPTIONS, setReadonlyId)} +
{renderSelect('Model', modelId, MODEL_OPTIONS, setModelId)} @@ -190,11 +192,11 @@ export function InputBar({ onClick={onPrimary} > {running ? ( - + ) : ( - + )} diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 3043c71ed5..edd9f7d54d 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -78,6 +78,7 @@ async function bench() { cell: () => undefined, scopeOf, create: vi.fn(() => Promise.resolve(ROOT)), + createWorkspace: vi.fn(() => Promise.resolve(ROOT)), open: vi.fn(), } ctx.provide('sessions', sessionsFake) @@ -239,16 +240,20 @@ describe('details and empty inject surfaces', () => { expect(details).toBe(conv) }) - it('empty injects the startSession chain only (no store, cwds derive in-component)', async () => { + it('empty injects startSession and createWorkspaceSession (no store, cwds derive in-component)', async () => { const b = await bench() const entry = b.entryOf('conversation.empty') expect(entry.store).toBeUndefined() const injected = (entry.inject as unknown as () => EmptyStateInjected)() - expect(Object.keys(injected)).toEqual(['startSession']) + expect(Object.keys(injected).sort()).toEqual(['createWorkspaceSession', 'startSession']) await injected.startSession({ text: 'go', mode: 'queue' }) expect(b.sessionsFake.create).toHaveBeenCalled() expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'go' }], 'queue') + b.sessionsFake.open.mockClear() + await injected.createWorkspaceSession('Fresh') + expect(b.sessionsFake.createWorkspace).toHaveBeenCalledWith('Fresh') + expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT) }) it('startSession fails loud on a torn boot (conversation service fiber gone)', async () => { diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index b93b9c0b72..d1bd50437f 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -238,10 +238,16 @@ describe('DetailsPanel branches', () => { }) describe('EmptyState branches', () => { + const noopCreate = () => Promise.resolve() + it('keeps the draft and surfaces a local error strip when startSession rejects', async () => { const startSession = vi.fn(() => Promise.reject(new Error('create down'))) const view = render( - , + , ) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'first task' } }) @@ -253,7 +259,11 @@ describe('EmptyState branches', () => { it('non-Error rejection reasons stringify into the error strip', async () => { const startSession = vi.fn(() => Promise.reject('plain-string')) const view = render( - , + , ) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'go' } }) @@ -270,15 +280,17 @@ describe('EmptyState branches', () => { { id: 'b', title: 'b' }, // no cwd: filtered from the option set ])} startSession={startSession} + createWorkspaceSession={noopCreate} />, ) fireEvent.click(view.getByRole('button', { name: '项目目录' })) expect([...view.getByRole('menu').querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) - .toEqual(['Default directory', '/proj', 'New directory…']) - fireEvent.click(view.getByRole('menuitem', { name: '/proj' })) + .toEqual(['proj', 'New Workspace']) + fireEvent.click(view.getByRole('menuitem', { name: 'proj' })) expect(view.getByRole('button', { name: '项目目录' }).textContent).toContain('proj') fireEvent.click(view.getByRole('button', { name: '项目目录' })) - fireEvent.click(view.getByRole('menuitem', { name: 'New directory…' })) + fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) + fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' })) const custom = view.container.querySelector('input')! fireEvent.change(custom, { target: { value: '/typed/dir' } }) const textarea = view.container.querySelector('textarea')! @@ -286,4 +298,20 @@ describe('EmptyState branches', () => { fireEvent.keyDown(textarea, { key: 'Enter' }) await waitFor(() => expect(startSession).toHaveBeenCalledWith({ text: 'task', mode: 'queue', cwd: '/typed/dir' })) }) + + it('Create modal surfaces inject failures inline', async () => { + const createWorkspaceSession = vi.fn(() => Promise.reject(new Error('mkdir blocked'))) + const view = render( + Promise.resolve()} + createWorkspaceSession={createWorkspaceSession} + />, + ) + fireEvent.click(view.getByRole('button', { name: '项目目录' })) + fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) + fireEvent.click(view.getByRole('menuitem', { name: 'Create new' })) + fireEvent.click(view.getByRole('button', { name: 'Create' })) + await waitFor(() => expect(view.getByRole('alert').textContent).toContain('mkdir blocked')) + }) }) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index b2636bfd4b..a598803a25 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -86,6 +86,8 @@ function fakeSessions(rows: { id: string; title: string; cwd?: string; parentId? const SessionProviderStub: ConversationRootProps['SessionProvider'] = ({ children }) => <>{children(sid('s1'))} describe('EmptyState', () => { + const noopCreate = () => Promise.resolve() + it('derives cwd options from the sessions list, submits startSession, failure surfaces locally', async () => { const { useSessions } = fakeSessions([ { id: 'a', title: 'a', cwd: '/w/app' }, @@ -94,14 +96,20 @@ describe('EmptyState', () => { ]) let reject!: (e: Error) => void const startSession = vi.fn(() => new Promise((_res, rej) => { reject = rej })) - render() + render( + , + ) const trigger = screen.getByRole('button', { name: '项目目录' }) fireEvent.click(trigger) const menu = screen.getByRole('menu') expect([...menu.querySelectorAll('[role="menuitem"]')].map(el => el.textContent)) - .toEqual(['Default directory', '/w/app', '/w/lib', 'New directory…']) - fireEvent.click(screen.getByRole('menuitem', { name: '/w/app' })) + .toEqual(['app', 'lib', 'New Workspace']) + fireEvent.click(screen.getByRole('menuitem', { name: 'app' })) const box = screen.getByPlaceholderText('Message to run task, plan and build, enter for / commands') fireEvent.change(box, { target: { value: '造一个轮子' } }) fireEvent.keyDown(box, { key: 'Enter' }) @@ -113,14 +121,64 @@ describe('EmptyState', () => { expect((box as HTMLTextAreaElement).value).toBe('造一个轮子') }) - it('new-directory option swaps the chip for a free-form input', () => { + it('Use a existing folder opens the path modal and Open Folder sets the chip', () => { const { useSessions } = fakeSessions([]) - render( Promise.resolve()} />) + render( + Promise.resolve()} + createWorkspaceSession={noopCreate} + />, + ) fireEvent.click(screen.getByRole('button', { name: '项目目录' })) - fireEvent.click(screen.getByRole('menuitem', { name: 'New directory…' })) - const custom = screen.getByPlaceholderText(/Directory path/) - fireEvent.change(custom, { target: { value: '/tmp/fresh' } }) - expect((custom as HTMLInputElement).value).toBe('/tmp/fresh') + const newWs = screen.getByRole('menuitem', { name: 'New Workspace' }) + fireEvent.mouseEnter(newWs.parentElement as HTMLElement) + fireEvent.click(screen.getByRole('menuitem', { name: 'Use a existing folder' })) + expect(screen.getByRole('dialog', { name: 'Enter an existing folder path' })).toBeTruthy() + const path = screen.getByLabelText('Folder path') as HTMLInputElement + fireEvent.change(path, { target: { value: '/tmp/fresh' } }) + fireEvent.click(screen.getByRole('button', { name: 'Open Folder' })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(screen.getByRole('button', { name: '项目目录' }).textContent).toContain('fresh') + }) + + it('Create new opens the modal and createWorkspaceSession succeeds', async () => { + const { useSessions } = fakeSessions([]) + const createWorkspaceSession = vi.fn(() => Promise.resolve()) + render( + Promise.resolve()} + createWorkspaceSession={createWorkspaceSession} + />, + ) + fireEvent.click(screen.getByRole('button', { name: '项目目录' })) + fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) + fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' })) + expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeTruthy() + const name = screen.getByLabelText('Workspace name') as HTMLInputElement + expect(name.value).toBe('New WorkSpace') + fireEvent.change(name, { target: { value: 'My Proj' } }) + fireEvent.keyDown(name, { key: 'Enter' }) + await vi.waitFor(() => expect(createWorkspaceSession).toHaveBeenCalledWith('My Proj')) + }) + + it('Create modal Cancel dismisses without calling createWorkspaceSession', () => { + const { useSessions } = fakeSessions([]) + const createWorkspaceSession = vi.fn(() => Promise.resolve()) + render( + Promise.resolve()} + createWorkspaceSession={createWorkspaceSession} + />, + ) + fireEvent.click(screen.getByRole('button', { name: '项目目录' })) + fireEvent.mouseEnter(screen.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) + fireEvent.click(screen.getByRole('menuitem', { name: 'Create new' })) + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog')).toBeNull() + expect(createWorkspaceSession).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index da6382be4b..5b158c453a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-primitives -Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8. +Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, markdown family (MessageText/MarkdownText/JsonBlock). Contract: api-contracts v3 §8. ## Markdown rendering diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index fda27fdd4d..44c35eb517 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", - "description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Input, markdown family (zero cordis)", + "description": "Pure React atoms for the dsh web UI: StateDot, ic_ds_* icon set, Button/Pill/Menu/Modal/Input, markdown family (zero cordis)", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/client/ui-primitives/src/Button.module.css b/packages/client/ui-primitives/src/Button.module.css index 1cb3b18194..3f415b5d15 100644 --- a/packages/client/ui-primitives/src/Button.module.css +++ b/packages/client/ui-primitives/src/Button.module.css @@ -56,6 +56,20 @@ background: var(--dsw-alias-interactive-bg-active); } +/* Dialog Cancel (figma 451:18655): bordered capsule on transparent fill. */ +.outline { + border: 1px solid var(--dsw-alias-border-l2); + background: transparent; +} + +.outline:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.outline:disabled { + border-color: var(--dsw-alias-border-l1); +} + .toolbar { background: var(--dsw-alias-button-tool-bar-fill); } diff --git a/packages/client/ui-primitives/src/Button.tsx b/packages/client/ui-primitives/src/Button.tsx index 028c1fc266..642372868a 100644 --- a/packages/client/ui-primitives/src/Button.tsx +++ b/packages/client/ui-primitives/src/Button.tsx @@ -6,7 +6,7 @@ import clsx from 'clsx' import css from './Button.module.css' /** Visual variant, each backed by its --dsw-alias-button-* token family. */ -export type ButtonVariant = 'primary' | 'ghost' | 'toolbar' +export type ButtonVariant = 'primary' | 'ghost' | 'outline' | 'toolbar' /** * Render a button. diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index 3e3bf85299..3cbcb62d29 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -3,21 +3,32 @@ display: inline-flex; } -/* Dropdown card (figma MenuDropdown 122:10096): white card, r12, no border, - * layered drop shadows via the shadow token, 4px inset padding. */ +/* Dropdown card (figma MenuDropdown 122:9481 / 419:16920): menu surface, + * r12, inverted hairline border, shadow-lv3, 4px inset padding. */ +.list, +.submenu { + padding: 4px; + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 12px; + background: var(--dsw-specific-menu); + box-shadow: var(--dsw-shadow-lv3); +} + .list { position: absolute; top: calc(100% + 4px); left: 0; z-index: 100; min-width: 130px; - padding: 4px; - display: flex; - flex-direction: column; - gap: 0; - border-radius: 12px; - background: var(--dsw-alias-bg-layer-1); - box-shadow: var(--dsw-shadow-lv2); +} + +/* Open above the anchor (empty-state workspace chip: figma 122:9481). */ +.sideTop { + top: auto; + bottom: calc(100% + 4px); } .alignEnd { @@ -25,12 +36,18 @@ right: 0; } -/* Menu cell (figma .Menu_cell 27:5169): r10, pad 10/8, 14/22 primary text, +.itemWrap { + position: relative; +} + +/* Menu cell (figma .Menu_cell): min-h 40, r10, pad 10/8, 14/22 primary, * gap 8 between leading icon / label / trailing check. */ .item { display: flex; align-items: center; gap: 8px; + width: 100%; + min-height: 40px; padding: 8px 10px; border: none; border-radius: 10px; @@ -51,9 +68,22 @@ cursor: not-allowed; } +.itemIcon { + display: inline-flex; + flex: none; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + color: var(--dsw-alias-label-tertiary); +} + .itemLabel { flex: 1; min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .check { @@ -66,3 +96,34 @@ .selected { background: transparent; } + +/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */ +.separator { + height: 1px; + margin: 4px 2px; + background: var(--dsw-alias-border-l1); +} + +/* Nested card to the right of the parent row (figma 419:16920). + * Bottom-aligned with the parent menu card (grows upward): itemWrap sits in + * .list's 4px pad, so bottom: -4px matches the list's outer bottom edge. + * Horizontal: list pad (4px) + 6px card gap = 10px past itemWrap — plain + * `100% + 6px` collapses to ~2px between outer card edges. + * ::before bridges the full gap so the pointer can cross without mouseLeave. */ +.submenu { + position: absolute; + top: auto; + bottom: -4px; + left: calc(100% + 10px); + z-index: 101; + min-width: 160px; +} + +.submenu::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: -10px; + width: 10px; +} diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index ad45acc221..0b07c26357 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -1,46 +1,69 @@ // Menu: minimal controlled dropdown (group-by pickers, project selectors). // Pure CSS positioning relative to the anchor wrapper — no portal, no popper. // The owner controls `open`; outside-click closing uses one document listener -// active only while open. +// active only while open. Submenus open on hover/focus inside the same root. -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import clsx from 'clsx' import { IconCheckOutline16 } from './icons/index.tsx' import css from './Menu.module.css' -/** One selectable menu row. */ +/** Selectable row (optionally with a nested submenu). */ export interface MenuItem { id: string label: ReactNode disabled?: boolean + /** Leading icon (figma .Menu_cell gap 8). */ + icon?: ReactNode + /** Nested card opened to the right on hover/focus. */ + submenu?: readonly MenuItem[] +} + +/** Hairline between item groups (not selectable). */ +export interface MenuSeparator { + type: 'separator' + id: string +} + +/** One primary-menu entry: a row or a separator. */ +export type MenuEntry = MenuItem | MenuSeparator + +function isSeparator(entry: MenuEntry): entry is MenuSeparator { + return 'type' in entry && entry.type === 'separator' } /** * Render an anchored dropdown menu. * @param props.open - whether the list is showing (owner-controlled). * @param props.anchor - the trigger element (rendered in place). - * @param props.items - selectable rows. + * @param props.items - selectable rows and optional separators. * @param props.selectedId - row shown as selected. - * @param props.onSelect - row click callback (not called for disabled rows). + * @param props.onSelect - row click callback (not called for disabled rows or submenu parents that only open children). * @param props.onClose - invoked on outside click or Escape. * @param props.align - list alignment against the anchor (default 'start'). + * @param props.side - open below (`bottom`, default) or above (`top`) the anchor. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', className }: { open: boolean anchor: ReactNode - items: readonly MenuItem[] + items: readonly MenuEntry[] selectedId?: string onSelect: (id: string) => void onClose: () => void align?: 'start' | 'end' + side?: 'bottom' | 'top' className?: string }) { const rootRef = useRef(null) + const [openSubmenuId, setOpenSubmenuId] = useState(null) useEffect(() => { - if (!open) return + if (!open) { + setOpenSubmenuId(null) + return + } const onPointerDown = (e: PointerEvent) => { if (rootRef.current && e.target instanceof Node && !rootRef.current.contains(e.target)) onClose() } @@ -59,21 +82,61 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align {anchor} {open && ( -
- {items.map(item => ( - - ))} +
+ {items.map(entry => { + if (isSeparator(entry)) { + return
+ } + const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 + const subOpen = hasSub && openSubmenuId === entry.id + return ( +
{ setOpenSubmenuId(hasSub ? entry.id : null) }} + onMouseLeave={() => { setOpenSubmenuId(null) }} + > + + {subOpen && entry.submenu !== undefined && ( +
+ {entry.submenu.map(sub => ( + + ))} +
+ )} +
+ ) + })}
)} diff --git a/packages/client/ui-primitives/src/Modal.module.css b/packages/client/ui-primitives/src/Modal.module.css new file mode 100644 index 0000000000..49026f7a5f --- /dev/null +++ b/packages/client/ui-primitives/src/Modal.module.css @@ -0,0 +1,79 @@ +/* Full-viewport layer (figma Mask + Dialog 451:18655): mask + centered card. */ +.root { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +/* User/spec mask: rgba(0,0,0,0.24) + blur(2px) via --dsw-alias-bg-mask-1 / + --dsw-mask-blur (light); dark theme raises mask opacity. */ +.mask { + position: absolute; + inset: 0; + background: var(--dsw-alias-bg-mask-1); + backdrop-filter: var(--dsw-mask-blur); +} + +/* Dialog card: r24, shadow-lv3, layer-2 fill, inverted border, pb 24. */ +.dialog { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + gap: 20px; + width: min(380px, 100%); + padding: 0 0 24px; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-inverted); + border-radius: 24px; + background: var(--dsw-alias-bg-layer-2); + box-shadow: var(--dsw-shadow-lv3); +} + +.content { + display: flex; + flex-direction: column; + width: 100%; +} + +/* Header pad (figma Title row): pt 22 / pl 24 / pr 14 / pb 12. */ +.header { + display: flex; + flex-direction: column; + gap: 8px; + padding: 22px 14px 12px 24px; +} + +.title { + margin: 0; + font-size: 16px; + line-height: 24px; + font-weight: 500; + color: var(--dsw-alias-label-primary); +} + +.description { + margin: 0; + font-size: 14px; + line-height: 22px; + color: var(--dsw-alias-label-secondary); +} + +.body { + display: flex; + flex-direction: column; + min-width: 0; + padding: 0 24px; +} + +.footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + padding: 0 24px; +} diff --git a/packages/client/ui-primitives/src/Modal.tsx b/packages/client/ui-primitives/src/Modal.tsx new file mode 100644 index 0000000000..cdbe1060bf --- /dev/null +++ b/packages/client/ui-primitives/src/Modal.tsx @@ -0,0 +1,62 @@ +// Modal: controlled full-viewport dialog (create-workspace and similar). +// Fixed overlay in the React tree (no react-dom portal) so ui-primitives +// stays free of a react-dom dependency; mask tokens match figma 451:18655. + +import { useEffect } from 'react' +import type { ReactNode } from 'react' +import clsx from 'clsx' +import css from './Modal.module.css' + +/** + * Render a centered modal over a blurred page mask. + * @param props.open - whether the dialog is showing. + * @param props.onClose - Escape or mask click. + * @param props.title - dialog heading. + * @param props.description - optional supporting sentence under the title. + * @param props.children - body (inputs, etc.). + * @param props.footer - action row (Cancel / Create). + * @returns null when closed; otherwise the overlay tree. + */ +export function Modal({ open, onClose, title, description, children, footer, className }: { + open: boolean + onClose: () => void + title: string + description?: string + children?: ReactNode + footer?: ReactNode + className?: string +}) { + useEffect(() => { + if (!open) return + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', onKeyDown) + return () => { document.removeEventListener('keydown', onKeyDown) } + }, [open, onClose]) + + if (!open) return null + + return ( +
+ + ) +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index e5e2e4e88f..0d6cde4ed0 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -1,5 +1,5 @@ /** - * Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Input, + * Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Modal/Input, * markdown family, ConnectionBanner. Everything consumes props plus --dsw-* * token vars only. Contract: api-contracts v3 section 8. */ @@ -11,7 +11,8 @@ export type { ButtonVariant } from './Button.tsx' export { Pill } from './Pill.tsx' export { Input } from './Input.tsx' export { Menu } from './Menu.tsx' -export type { MenuItem } from './Menu.tsx' +export type { MenuEntry, MenuItem, MenuSeparator } from './Menu.tsx' +export { Modal } from './Modal.tsx' export { ConnectionBanner } from './ConnectionBanner.tsx' export { FishLogo } from './FishLogo.tsx' export { BrandWordmark } from './BrandWordmark.tsx' diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index f259cb334a..a4b286ced7 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Button, ConnectionBanner, Input, Menu, Pill } from '@deepseek-ai/dsh-client-ui-primitives' +import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) @@ -21,6 +21,11 @@ describe('Button', () => { fireEvent.click(screen.getByRole('button')) expect(onClick).not.toHaveBeenCalled() }) + + it('outline variant renders a bordered cancel-style button', () => { + render() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDefined() + }) }) describe('Pill', () => { @@ -91,11 +96,12 @@ describe('Menu', () => { expect(onClose).not.toHaveBeenCalled() }) - it('selected item shows the trailing check; align=end and className apply', () => { + it('selected item shows the trailing check; align=end, side=top, and className apply', () => { const { container } = render( trigger} items={items} @@ -104,12 +110,89 @@ describe('Menu', () => { onClose={() => {}} />) expect((container.firstElementChild as HTMLElement).classList.contains('x')).toBe(true) + const menu = screen.getByRole('menu') + expect(menu.className).toMatch(/sideTop|alignEnd/) const selected = screen.getByRole('menuitem', { name: 'Alpha' }) expect(selected.querySelector('svg')).not.toBeNull() const other = screen.getByRole('menuitem', { name: 'Beta' }) expect(other.querySelector('svg')).toBeNull() fireEvent.keyDown(document, { key: 'a' }) }) + + it('renders a leading icon and a separator between groups', () => { + render( + trigger} + items={[ + { id: 'a', label: 'Alpha', icon: }, + { type: 'separator', id: 's1' }, + { id: 'c', label: 'Create' }, + ]} + onSelect={() => {}} + onClose={() => {}} + />) + expect(screen.getByTestId('ic')).toBeDefined() + expect(screen.getByRole('separator')).toBeDefined() + }) + + it('opens a submenu on hover and selects a nested item', () => { + const onSelect = vi.fn() + render( + trigger} + items={[ + { id: 'plain', label: 'Plain' }, + { + id: 'new', + label: 'New Workspace', + submenu: [ + { id: 'ok', label: 'Create ok', icon: }, + ], + }, + ]} + onSelect={onSelect} + onClose={() => {}} + />) + const plain = screen.getByRole('menuitem', { name: 'Plain' }) + fireEvent.mouseEnter(plain.parentElement as HTMLElement) + fireEvent.focus(plain) + const parent = screen.getByRole('menuitem', { name: 'New Workspace' }) + const wrap = parent.parentElement as HTMLElement + fireEvent.click(parent) + expect(onSelect).not.toHaveBeenCalled() + fireEvent.focus(parent) + fireEvent.mouseEnter(wrap) + expect(screen.getByTestId('sub-ic')).toBeDefined() + fireEvent.click(screen.getByRole('menuitem', { name: 'Create ok' })) + expect(onSelect).toHaveBeenCalledWith('ok') + fireEvent.mouseLeave(wrap) + expect(screen.queryByRole('menuitem', { name: 'Create ok' })).toBeNull() + }) +}) + +describe('Modal', () => { + it('is absent while closed; Escape and mask click call onClose', () => { + const onClose = vi.fn() + const { rerender } = render( + body) + expect(screen.queryByRole('dialog')).toBeNull() + rerender( + Create}> + + ) + expect(screen.getByRole('dialog', { name: 'Create new workspace' })).toBeDefined() + expect(screen.getByText('Name it.')).toBeDefined() + fireEvent.keyDown(document, { key: 'a' }) + expect(onClose).not.toHaveBeenCalled() + fireEvent.keyDown(document, { key: 'Escape' }) + expect(onClose).toHaveBeenCalledTimes(1) + // Mask is the presentation sibling behind the dialog. + const mask = document.querySelector('[aria-hidden="true"]') as HTMLElement + fireEvent.click(mask) + expect(onClose).toHaveBeenCalledTimes(2) + }) }) describe('ConnectionBanner', () => { diff --git a/packages/host/runtime/src/api-proxy.ts b/packages/host/runtime/src/api-proxy.ts index 2d674912cd..3792d17da3 100644 --- a/packages/host/runtime/src/api-proxy.ts +++ b/packages/host/runtime/src/api-proxy.ts @@ -4,7 +4,7 @@ */ import { randomUUID } from 'node:crypto' -import { stat } from 'node:fs/promises' +import { mkdir, stat } from 'node:fs/promises' import type { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' @@ -407,8 +407,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const sessionId = `session-${randomUUID()}` as SessionId // A session's cwd is its project path. When the creator does not choose // one, the default project is the host-level default (the host process - // working directory unless boot overrides it). + // working directory unless boot overrides it). Ensure the directory + // exists so Create-workspace and typed paths land on a real folder. const cwd = request.payload.cwd ?? defaults.cwd + try { + await mkdir(cwd, { recursive: true }) + } catch (error: unknown) { + return err(request, { + code: 'internal', + message: `failed to ensure project directory "${cwd}": ${String(error)}`, + details: {}, + }) + } const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } }) return ok(request, { sessionId: handle.agent.id }) }, diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index c30e07b50b..4058fdfe2e 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -1,4 +1,4 @@ -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -231,6 +231,29 @@ describe('sessions.create / list', () => { expect(first?.running).toBe(false) expect(first?.parentSessionId).toBeUndefined() }) + + it('ensures a missing project directory before minting the session', async () => { + const { api } = await boot() + const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-')) + const cwd = join(root, 'nested', 'workspace') + expect(existsSync(cwd)).toBe(false) + const { sessionId } = expectOk(await api.sessions.create(request({ cwd }))) + expect(existsSync(cwd)).toBe(true) + const { items } = expectOk(await api.sessions.list(request({}))) + expect(items.find(item => item.sessionId === sessionId)?.cwd).toBe(cwd) + }) + + it('fails loud when the project directory cannot be created', async () => { + const { api } = await boot() + const root = mkdtempSync(join(tmpdir(), 'dsh-host-create-cwd-fail-')) + const blocker = join(root, 'file-not-dir') + writeFileSync(blocker, 'x') + const response = await api.sessions.create(request({ cwd: join(blocker, 'child') })) + expect(response.result.ok).toBe(false) + if (response.result.ok) throw new Error('expected mkdir failure') + expect(response.result.error.code).toBe('internal') + expect(response.result.error.message).toMatch(/failed to ensure project directory/) + }) }) describe('sessions.prompt / cancel', () => { From 2ae9f4fdf3a0087d4dc90b14a71486af676a8a0e Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 24 Jul 2026 12:31:26 +0800 Subject: [PATCH 09/15] feat(tui): add safe session resume flow --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 34 +- .../2026-07-21-tui-resume-command.zh.md | 34 +- apps/cli/README.md | 2 +- apps/cli/package.json | 4 +- apps/cli/src/tui.ts | 37 +- apps/cli/tsconfig.json | 3 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- docs/config-catalog.md | 25 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 38 +- docs/core-data-structures/persistence.md | 12 + docs/core-data-structures/session-query.md | 12 +- docs/event-producer-consumer.md | 2 +- examples/tui-agent/README.md | 2 +- .../tests/fixtures/tui-scripted.cordis.yml | 2 + .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 50 +- .../cordis/tool-cordis/src/api-catalog.ts | 20 + packages/core/agent-loop/src/index.ts | 31 +- packages/core/agent-loop/tests/resume.spec.ts | 45 ++ packages/examples/tui-demo/README.md | 3 +- .../session-persistence-jsonl/README.md | 8 +- .../session-persistence-jsonl/src/index.ts | 122 +++- .../tests/fixtures/live-lease-child.ts | 16 + .../tests/fixtures/live-lease-race-child.ts | 33 + .../tests/jsonl.spec.ts | 182 ++++- .../session-persistence-sqlite/README.md | 4 +- .../session-persistence-sqlite/src/index.ts | 67 +- .../session-persistence-sqlite/src/schema.ts | 11 +- .../tests/sqlite.spec.ts | 35 +- .../session-persistence/README.md | 8 +- .../session-persistence/src/coordinator.ts | 86 ++- .../session-persistence/src/index.ts | 38 ++ .../session-persistence/src/lease.ts | 98 +++ .../session-persistence/tests/lease.spec.ts | 62 ++ .../tests/persistence.spec.ts | 58 +- .../session-query/session-query/README.md | 1 + .../session-query/session-query/src/index.ts | 18 +- .../session-query/session-query/src/types.ts | 8 + .../session-query/tests/session-query.spec.ts | 19 + packages/ui/app-boot/README.md | 3 +- packages/ui/app-boot/src/index.ts | 19 +- packages/ui/app-boot/tests/app-boot.spec.ts | 24 +- packages/ui/tui/README.md | 9 +- packages/ui/tui/package.json | 9 + packages/ui/tui/src/index.ts | 404 ++++++++++- packages/ui/tui/tests/harness.ts | 32 +- packages/ui/tui/tests/plugin-shape.spec.ts | 1 + .../snapshots/resume-sessions.expected.txt | 69 +- packages/ui/tui/tests/tui.snapshot.ts | 25 +- packages/ui/tui/tests/tui.spec.ts | 634 ++++++++++++++++-- packages/ui/tui/tsconfig.json | 6 + pnpm-lock.yaml | 9 + scripts/gen-cordis-catalog.ts | 2 + scripts/type-equiv.manifest.json | 10 + 57 files changed, 2312 insertions(+), 192 deletions(-) create mode 100644 packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts create mode 100644 packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts create mode 100644 packages/session-persistence/session-persistence/src/lease.ts create mode 100644 packages/session-persistence/session-persistence/tests/lease.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index 210215eb3d..42370c5dad 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 2282eaa9bff83fdb75bdce315d6b17bf8f9ea303 -2026-07-21-tui-resume-command.zh.md: f9d989a5b4e7eb106ff21c5a4fcfa770a5962343 +2026-07-21-tui-resume-command.md: 23755696a9b7b379f0341c472769684839b37211 +2026-07-21-tui-resume-command.zh.md: cd2e19a2ef95409e8e11199f08afa996e8b07414 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 2282eaa9bf..23755696a9 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -1,4 +1,4 @@ -# Agent Note: Resume command hint and `/resume` +# Agent Note: Product-level TUI session resume Status: implemented @@ -6,36 +6,36 @@ English | [中文](2026-07-21-tui-resume-command.zh.md) ## Problem -The TUI can resume a session by launch (`RESUME_SESSION_ID= dsh` feeding `dsh-tui-demo`'s `resumeSessionId`), but nothing told the user the command. On exit the session id survived only in the log and `./.sessions` filenames — the [no-banner Agent Note](2026-07-21-tui-no-banner.md) removed the last place it was shown — so resuming meant hunting for the id and reconstructing the invocation. There was also no in-session way to see which sessions in this workspace are resumable. +The original `/resume` printed shell commands. It did not let a keyboard user inspect titles or outcomes, distinguish corruption from a missing adapter, detect another live owner, or safely transfer the terminal. Leaving the TUI and manually launching a command also hid the required ordering: finish current work, flush it, release the UI and app, then restore the exact persisted identity without silently creating a replacement. ## Decision -A single optional `resumeCommand` config field on `dsh-tui` gates both surfaces: a shell command template whose every `{session}` is replaced with the live session id (e.g. `dsh --resume {session}`). Absent, neither surface appears. +`/resume` uses the TUI's existing interactive overlay seam. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and another live owner's session remain visible but disabled. -- **Exit hint.** Process-exiting shutdown prints `To resume this session: ` (muted label) via `runtime.terminal.write` after `ui.stop()`, before `runtime.exit`. It prints only once the session is durably persisted: `currentResumeCommand()` scans the session list for the current id and returns `undefined` if it is absent, so a session abandoned before its first flush advertises no command that would fail to load. -- **`/resume`.** Lists this workspace's persisted sessions newest-first, each with its resume command, marking the current one `(current)`. It warns when `resumeCommand` is unconfigured or no persistence backend is mounted, and notes when nothing is persisted yet. The listing is asynchronous, so the transcript updates a tick after submit. -- **Listing.** `listWorkspaceSessions()` reads the optional `sessionPersistence` service's `list()`, keeps headers whose `cwd === agent.session.header.cwd`, and sorts by `createdAt` descending. A `list()` rejection is swallowed to `[]` — a persistence failure must never block terminal exit or crash `/resume`. +`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate repeats the load, cwd, occupancy, and route checks so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. Running agents are never switched or cancelled implicitly. -`sessionPersistence` is an optional injected service reached through `ctx.get('sessionPersistence')` (not `inject`), declared as an optional peer dependency. Without a backend the field still parses; the exit hint and `/resume` degrade to nothing and the unconfigured/no-backend warnings respectively. `dsh-tui-demo` forwards `resumeCommand` to `dsh-tui`, and the runnable `examples/tui-agent` leaves set `dsh --resume {session}`. The `dsh` CLI (`apps/cli`) parses that `--resume ` flag through `parseResumeArg` in [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md), setting `RESUME_SESSION_ID` before boot so the printed command runs back through the config's existing `resumeSessionId` intake; a mistyped or repeated flag fails loud rather than silently starting fresh. +First-party persistence backends implement a cross-process live lease under the shared coordinator. JSONL uses an owner-only lock record; SQLite uses a `live_session_leases` row. Both retain PID plus an exec-stable nonce, reject another live process, reclaim a dead PID, and release only after the exact session lifecycle drains. `AgentLoop.resume()` claims before load, closing the preflight/start race. + +After preflight, the TUI flushes the current session and stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than spawning a second terminal owner. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. + +`resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable. ## Alternatives considered -**Hardcode or auto-detect the resume invocation.** Rejected: the launch command is deployment-specific — the env-var name, binary, and flags all vary — so a `DEFAULT_*` constant would be a fixed tunable, not configurability. A template owned by the leaf keeps the choice where the deployment lives, and `{session}` is the only substitution the TUI must know. +**Have the TUI spawn `resumeCommand`.** Rejected: the template is deployment text, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`. -**Two config fields, one per surface.** Rejected: both render the identical command, so one field keeps them symmetric and unable to drift; there is no deployment that wants the hint but not the listing. +**Construct the resumed agent inside the existing TUI.** Rejected: replacing one config-created agent would cross Loader ownership, scoped plugin setup, persistence retirement, and terminal lifecycle in the presentation layer. Root disposal plus process replacement reuses the supported startup path. -**Print the exit hint unconditionally.** Rejected: resuming a session id that never flushed fails to load, so advertising it is a broken instruction. Gating on the id appearing in `list()` costs one scan and only ever suppresses a dead command. +**Treat a missing adapter as a missing session.** Rejected: storage validity and current route availability are independent facts. The selector keeps the row and names the unavailable provider/model. -**Resume in place from `/resume` (relaunch or reattach).** Rejected: the TUI does not own agent lifecycle or process spawning ([front-door Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md)). Printing a copyable command respects that boundary and matches the `pi --resume` affordance the request cited. - -**Make `sessionPersistence` a required `inject`.** Rejected: the TUI must run without persistence (fixtures, ephemeral runs). An optional service that degrades preserves that, and matches the [`session-query`](../../../../packages/session-query/session-query/package.json) precedent for the same optional peer. +**Persist goal activation across resume.** Rejected: durable intent is not authorization to continue after a human or process boundary. Goal phase survives; automatic continuation does not. ## Consequences -- `dsh-tui` gains an optional peer dependency on `@deepseek-ai/dsh-session-persistence` (`peerDependenciesMeta.optional`), matching `session-query`; the package still loads and passes its coverage gate without a backend mounted. -- The help line and autocomplete gain `/resume`; two existing snapshots re-recorded for the wider help line, and a new `resume-sessions` checkpoint pins the rendered listing. -- `dsh-tui-demo` and both `examples/tui-agent` leaves carry `resumeCommand`, so a real TUI run now prints its own resume command on exit, and the `dsh` CLI accepts the printed `--resume ` flag to run it. +- Persistence schema and artifact layout include live leases; SQLite advances its unreleased schema version and rejects older databases under the repository's pre-release policy. +- `/resume` depends on `session-query` for discovery and complete-log reads, but persistence and host handoff remain optional; without a host, the command fallback stays usable. +- Process replacement intentionally restarts Loader composition. Runtime-only state is rebuilt, while only logged or header-backed session state survives. ## Testing -`packages/ui/tui/tests/tui.spec.ts` pins the seven behaviors: the exit hint prints only when the current session is persisted, is omitted when it is not and when `list()` rejects; `/resume` lists workspace sessions newest-first with the `(current)` marker and cwd filter, warns when unconfigured and when no backend is mounted, and notes when nothing is persisted. The `resume-sessions` snapshot verifies the full rendered frame. The harness provides a fake `sessionPersistence` through `ctx.provide`. For the `--resume` flag, `packages/ui/app-boot/tests/app-boot.spec.ts` pins `parseResumeArg` (space and inline forms, position independence, and the fail-loud on a valueless, empty, or repeated flag), and `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots `apps/cli` with `--resume ` and asserts the config resume fails loud — proving the flag reaches the `resumeSessionId` intake. +TUI tests cover keyboard navigation, title/id search, Escape cancellation, running-agent refusal, route absence, occupied and corrupt rows, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Persistence contracts retain valid/corrupt/interrupted behavior, while a real JSONL child process proves another owner is disabled and its crashed lease is reclaimed. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the visible selector frame. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index f9d989a5b4..cd2e19a2ef 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -1,4 +1,4 @@ -# Agent Note: Resume command hint and `/resume` +# Agent Note: 产品级 TUI 会话恢复 Status: implemented @@ -6,36 +6,36 @@ Status: implemented ## Problem -TUI 本就能通过启动参数恢复会话(`RESUME_SESSION_ID= dsh` 喂给 `dsh-tui-demo` 的 `resumeSessionId`),但没有任何地方告诉用户这条命令。退出时会话 id 只残留在会话日志和 `./.sessions` 文件名里——[移除启动横幅 Agent Note](2026-07-21-tui-no-banner.md) 移除了它最后一处显示位置——因此恢复意味着先翻出 id 再拼回调用命令。也没有任何会话内的方式查看当前 workspace 里哪些会话可恢复。 +原有 `/resume` 只会打印 shell 命令。使用键盘操作的用户无法查看标题或结果、区分日志损坏与适配器缺失、发现另一个活跃所有者,也无法安全移交终端。退出 TUI 后手动启动命令还掩盖了必要的操作顺序:等待当前工作结束并将其刷写,释放 UI 和应用,再恢复持久化的原有身份,绝不能静默创建替代会话。 ## Decision -`dsh-tui` 上一个可选的 `resumeCommand` 配置字段同时管辖两处出口:一个 shell 命令模板,其中每一处 `{session}` 都会被替换为当前会话 id(例如 `dsh --resume {session}`)。未设置时两处都不出现。 +`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和被另一个活跃进程占用的会话仍会显示,但不可选择。 -- **退出提示。** 以退出进程方式关闭时,在 `ui.stop()` 之后、`runtime.exit` 之前,经由 `runtime.terminal.write` 打印 `To resume this session: `(弱化的标签)。仅当会话已持久化时才打印:`currentResumeCommand()` 在会话列表中查找当前 id,若不存在则返回 `undefined`,因此在首次刷盘前就被放弃的会话不会宣传一条注定加载失败的命令。 -- **`/resume`。** 按最新在前列出当前 workspace 里已持久化的会话,每条附带其恢复命令,并给当前会话标注 `(current)`。当 `resumeCommand` 未配置或未挂载持久化后端时给出告警,尚无任何会话被持久化时给出提示。列出是异步的,因此提交后文本记录会在下一个 tick 更新。 -- **列出逻辑。** `listWorkspaceSessions()` 读取可选的 `sessionPersistence` 服务的 `list()`,保留 `cwd === agent.session.header.cwd` 的头部,并按 `createdAt` 降序排序。`list()` 拒绝时吞掉为 `[]`——持久化失败绝不能阻塞终端退出或让 `/resume` 崩溃。 +`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会再次检查日志加载、cwd、占用情况和路由,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。系统绝不会隐式切换或取消处于运行状态的 agent。 -`sessionPersistence` 是一个通过 `ctx.get('sessionPersistence')`(而非 `inject`)获取的可选注入服务,声明为可选的对等依赖(peer dependency)。没有后端时该字段仍能解析;退出提示与 `/resume` 分别退化为不做任何事、以及给出未配置/无后端告警。`dsh-tui-demo` 将 `resumeCommand` 转发给 `dsh-tui`,可运行的 `examples/tui-agent` 叶子配置设为 `dsh --resume {session}`。`dsh` CLI(`apps/cli`)通过 [`dsh-app-boot`](../../../../packages/ui/app-boot/README.md) 中的 `parseResumeArg` 解析该 `--resume ` 标志,在启动前设置 `RESUME_SESSION_ID`,因此打印出的命令会重新走回配置中既有的 `resumeSessionId` 入口;拼写错误或重复的标志会直接报错退出,而非悄悄开启一个新会话。 +第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 的租约,并且仅在对应会话生命周期完全停稳后释放租约。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。 + +预检通过后,TUI 先刷写当前会话并停止终端,再调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 + +`resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。 ## Alternatives considered -**硬编码或自动探测恢复调用命令。** 否决:启动命令与部署强相关——环境变量名、可执行文件、参数都各不相同——因此一个 `DEFAULT_*` 常量只会是固定的可调项,而非可配置项。由叶子拥有的模板把这个选择留在部署所在之处,而 `{session}` 是 TUI 唯一需要知道的替换。 +**让 TUI 创建 `resumeCommand` 进程。** 否决:该模板是部署文本,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。 -**两个配置字段,每处出口一个。** 否决:两处渲染的是完全相同的命令,因此单个字段让它们保持对称、不会漂移;不存在只想要提示而不想要列表的部署。 +**在现有 TUI 内构造恢复后的 agent。** 否决:在表现层替换由配置创建的 agent,会跨越 Loader 所有权、作用域插件初始化、持久化资源释放和终端生命周期。释放根应用并替换进程可以复用受支持的启动路径。 -**无条件打印退出提示。** 否决:恢复一个从未刷盘的会话 id 会加载失败,宣传它就是一条错误指令。以 id 是否出现在 `list()` 中为条件仅需一次扫描,且只会抑制一条注定失败的命令。 +**把适配器缺失视为会话缺失。** 否决:存储有效性和当前路由可用性是相互独立的事实。选择器会保留该行,并指出不可用的提供方/模型。 -**从 `/resume` 就地恢复(重启或重连)。** 否决:TUI 不拥有 agent 生命周期或进程创建([全屏 TUI 门面 Agent Note](2026-07-17-dedicated-full-screen-tui-front-door.md))。打印一条可复制的命令尊重这条边界,也契合需求所引用的 `pi --resume` 用法。 - -**把 `sessionPersistence` 设为必需的 `inject`。** 否决:TUI 必须能在无持久化时运行(fixture(测试前置数据)、临时运行)。一个会优雅退化的可选服务保住了这一点,也与 [`session-query`](../../../../packages/session-query/session-query/package.json) 对同一可选对等依赖的先例一致。 +**恢复会话时延续目标激活状态。** 否决:持久意图并不代表跨越用户或进程边界后仍获授权继续执行。目标阶段会保留,但不会自动续跑。 ## Consequences -- `dsh-tui` 新增对 `@deepseek-ai/dsh-session-persistence` 的可选对等依赖(`peerDependenciesMeta.optional`),与 `session-query` 一致;未挂载后端时该包仍能加载并通过其覆盖率门禁。 -- 帮助行和自动补全新增 `/resume`;两个既有快照因帮助行变宽而重新录制,新增的 `resume-sessions` 检查点固定渲染出的列表。 -- `dsh-tui-demo` 及两个 `examples/tui-agent` 叶子配置都带上 `resumeCommand`,因此真实的 TUI 运行现在退出时会打印自己的恢复命令,且 `dsh` CLI 接受打印出的 `--resume ` 标志来运行它。 +- 持久化 schema 和产物布局均包含活跃会话租约;SQLite 会推进其尚未发布的 schema 版本,并根据仓库的预发布政策拒绝旧数据库。 +- `/resume` 依赖 `session-query` 发现会话并读取完整日志,但持久化和宿主交接仍是可选功能;没有宿主时,命令回退仍可使用。 +- 进程替换会有意重启 Loader 组合。系统会重建仅存在于运行时的状态,而只有日志或会话头部记录的会话状态能够保留。 ## Testing -`packages/ui/tui/tests/tui.spec.ts` 固定这七种行为:退出提示仅在当前会话已持久化时打印,未持久化时以及 `list()` 拒绝时都不打印;`/resume` 按最新在前列出 workspace 会话并带 `(current)` 标注与 cwd 过滤、未配置时告警、无后端时告警、尚无持久化时给出提示。`resume-sessions` 快照验证完整渲染帧。测试脚手架通过 `ctx.provide` 提供一个假的 `sessionPersistence`。对于 `--resume` 标志,`packages/ui/app-boot/tests/app-boot.spec.ts` 固定 `parseResumeArg`(空格形式与内联形式、位置无关性,以及在标志缺值、为空或重复时直接报错退出),`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 用 `--resume ` 启动 `apps/cli` 并断言配置恢复直接报错退出——证明该标志抵达了 `resumeSessionId` 入口。 +TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、agent 运行期间拒绝恢复、路由缺失、被占用或损坏的候选行、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。持久化契约继续覆盖有效、损坏和中断的会话;真实 JSONL 子进程则证明另一个所有者占用的会话不可选择,并且进程崩溃后遗留的租约可以回收。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。 diff --git a/apps/cli/README.md b/apps/cli/README.md index f830b4647d..86bda3fbf5 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -5,7 +5,7 @@ The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are pro The TUI surface: - boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); -- resumes a persisted session with `dsh --resume ` — the form the TUI prints on exit and lists under `/resume`; the flag sets `RESUME_SESSION_ID` before boot so the shipped config rehydrates that session, and a missing or unreadable id fails loud and exits nonzero; +- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and atomically replaces the process with a normalized resume flag so only one runtime owns the terminal; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. diff --git a/apps/cli/package.json b/apps/cli/package.json index d7942c1e2d..8557965d34 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -29,6 +29,8 @@ "@deepseek-ai/dsh-host-runtime": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-tui": "workspace:^", + "cordis": "^4.0.0-rc.7" } } diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 6f97a68ad3..4a4ca8d7fc 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -19,9 +19,12 @@ import { loadEnv, loadPersonalPatches, parseResumeArg, + replaceResumeArg, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import type { Context } from 'cordis' +import type { TuiResumeHost } from '@deepseek-ai/dsh-tui' const NAME = 'dsh' @@ -65,7 +68,39 @@ export async function runTui(argv: string[]): Promise { // after loadEnv and before boot reads it through the config's `!!js`. const { resumeSessionId, rest } = parseResumeArg(argv) if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId - const ctx = await boot(NAME, resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME)) + const entry = process.argv[1] + const execve = process.execve?.bind(process) + const app: { current?: Context } = {} + const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : { + async handoff(sessionId): Promise { + const current = app.current + if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) + const nextArgv = [ + process.execPath, + ...process.execArgv, + entry, + ...replaceResumeArg(process.argv.slice(2), sessionId), + ] + process.env[RESUME_SESSION_ID_ENV] = sessionId + try { + await current.fiber.dispose() + execve(process.execPath, nextArgv, process.env) + throw new Error('process replacement returned unexpectedly') + } catch (error) { + process.stderr.write(`${NAME}: resume handoff failed after terminal release: ${String(error)}\n`) + process.exit(1) + } + }, + } + const ctx = await boot( + NAME, + resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), + loadPersonalPatches(NAME), + (hostCtx) => { + if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) + }, + ) + app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) } /* v8 ignore stop */ diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index cbc786d4c6..b33280943a 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -26,6 +26,9 @@ { "path": "../../packages/ui/app-boot" }, + { + "path": "../../packages/ui/tui" + }, { "path": "../../packages/util/paths" }, diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 20553e1b90..ea83179477 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: d1051eecf51d8d1c7f8c235b0c5dd80478b43316 -architecture.zh.md: 502c0248a9d2c62af165ce07eb19489b76c5f6ee +architecture.md: f0ce115d0b6e07a14d3c28288ea78f2c2f4294e7 +architecture.zh.md: eef66e3b9df6a3f00a48fc2c6d2e942b3fa9fe42 diff --git a/docs/architecture.md b/docs/architecture.md index d1051eecf5..f0ce115d0b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,7 +67,7 @@ The shipped loop runs prompt-to-checkpoint work through plugin services and even A **session** is append-only. Each ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits the preceding claimed turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it; a **step** is one model request plus tools. In the [sequence below](agent-lifecycle.md), quotes mark durable events. -Without an id, creation mints `-session-`; `sessionId` resumes or creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Setup failures emit `agent-loop/config-start-failed`; teardown is silent. +Creation without an id mints `-session-`; `sessionId` restores-or-creates, while `resumeSessionId` requires history. Resume claims a live lease before load, restores lineage and delegation depth before publication, and releases after quiescence. Startup failures emit `agent-loop/config-start-failed`; teardown is otherwise silent. ### Turn Flow @@ -147,7 +147,7 @@ The session log is authoritative. `deriveMessages()` projects model history; raw Durability is a plugin concern. Backends buffer synchronous `session/event` notifications. The semantic checkpoint policy drains requests before adapter dispatch, recorded top-level calls before tool dispatch, and complete response/result batches at `agent/post-step`; the loop retains the final turn-end checkpoint. `SessionPersistence` stores `SessionEvent` directly and metadata in `SessionHeader`; JSONL defaults to checksummed Zstandard, with SQLite under one contract ([decision](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md)). -`ctx.sessions.appendOutOfBand()` joins plugin-owned log-only events to an open turn or creates a balanced, flushed zero-step turn. `session/title` folds latest-wins with source seqs and provenance; its immediate fallback and sole optional async provider never delay the agent response. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). +`ctx.sessions.appendOutOfBand()` joins log-only events to an open turn or creates a flushed zero-step turn. `session/title` folds latest-wins with source seqs/provenance; fallback and its optional provider never delay responses. Forks inherit titles ([decision](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md)). ### Model Content diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 502c0248a9..eef66e3b9d 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -67,7 +67,7 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 ` **会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 -未提供 id 时,创建流程会生成 `-session-`;`sessionId` 用于恢复或创建会话,而 `resumeSessionId` 要求已有历史。恢复流程在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;拆卸过程保持静默。 +未提供 id 时会生成 `-session-`;`sessionId` 用于恢复或创建,而 `resumeSessionId` 要求已有历史。恢复流程在加载前领取活跃会话租约,在发布前还原沿袭关系和委托深度,并在系统停稳后释放租约。初始化失败会发出 `agent-loop/config-start-failed`;其余拆卸过程保持静默。 ### 轮次流程 @@ -147,7 +147,7 @@ forever: 持久性由插件负责。后端会缓冲同步的 `session/event` 通知。语义检查点策略会在适配器分发前刷写请求,在工具分发前刷写已记录的顶层调用,并在 `agent/post-step` 刷写完整的响应与结果批次;循环仍保留最终的轮次结束检查点。`SessionPersistence` 直接存储 `SessionEvent`,并将元数据存入 `SessionHeader`;JSONL 默认采用带校验和的 Zstandard,SQLite 则遵循同一契约([决策](../.agents/notes/implemented/bug-fix/2026-07-21-semantic-session-checkpoints.md))。 -`ctx.sessions.appendOutOfBand()` 会把插件所属的纯日志事件加入开放轮次,或创建一个平衡且已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq 和来源信息;其即时回退标题和唯一可选异步提供方都不会延迟 agent 响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 +`ctx.sessions.appendOutOfBand()` 会把纯日志事件加入开放轮次,或创建一个已刷写的零步骤轮次。`session/title` 按后写覆盖方式折叠,并携带源 seq/来源信息;回退标题及其可选提供方都不会延迟响应。fork 会继承标题([决策](../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md))。 ### 模型内容 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 985e493184..6b841214c5 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -113,7 +113,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:378`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -957,7 +957,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:39`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:40`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -996,7 +996,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:59`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query-sqlite` @@ -1571,7 +1571,7 @@ Source: [`packages/core/tools/src/index.ts:529`](../packages/core/tools/src/inde ## `@deepseek-ai/dsh-tui` -Requires: `agents` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` +Requires: `agents` · `sessions` · `commands` · `userInteraction` · `tools` · `llm` · `systemPrompt` · `tokenMeter` ```ts config-catalog /** Serializable plugin configuration. */ @@ -1581,11 +1581,10 @@ export interface Config extends TuiConfig { /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ sessionId?: string /** - * Shell command template shown for resuming this session: printed on exit and - * listed by `/resume`, with every `{session}` occurrence replaced by the live - * session id. Absent disables both surfaces. Deployments set it only when a - * persistence backend makes the session resumable (e.g. - * `RESUME_SESSION_ID={session} dsh`). + * Shell command fallback printed on exit or after selecting a session when + * the host cannot hand off in place. Every `{session}` becomes the selected + * id; the TUI never executes this text. Absent disables only the fallback, + * not the interactive selector. */ resumeCommand?: string } @@ -1600,6 +1599,8 @@ export interface TuiConfig { maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ maxModelOptions?: number + /** Maximum sessions visible at once in the resume selector. */ + maxResumeOptions?: number /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number /** User-question panel maximum height in terminal rows. */ @@ -1608,6 +1609,10 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Resume-selector width in terminal columns. */ + resumeDialogWidth?: number + /** Resume-selector maximum height in terminal rows. */ + resumeDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -1630,7 +1635,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:248`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:278`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 6becfd9434..9bda12f6b0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -399,7 +399,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:371`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c5d6d88d5c..5b3f7c4afb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise + +/** + * Atomically acquire this process's live ownership of a session id. + * Reentrant claims share one backend lease. First-party backends override + * this process-local fallback to reject another live process and reclaim a + * dead owner. + * @param id - session identity that is about to become live. + * @returns a single-release reference owned by the caller. + */ +claimLive(id: SessionId): Promise + +/** + * Check whether any process currently owns a live lease for this session. + * The base implementation reports only claims on this service instance. + * @param id - persisted or prospective session identity. + * @returns true while a non-stale lease exists, including this process's lease. + */ +isLive(id: SessionId): Promise ``` -Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) +Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLiveLease](../core-data-structures/persistence.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:55`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) @@ -996,6 +1014,14 @@ abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchE */ listSessions(): Promise +/** + * Read and replay-validate one complete logical session log without making it live. + * @param sessionId - live or persisted session id to read. + * @returns cloned header and complete raw event log from one observation. + * @throws when persistence, header compatibility, or replay validation fails. + */ +async readSession(sessionId: SessionId): Promise + /** * Filter the complete logical corpus with provider-independent predicates. * @param filters - ANDed session metadata and availability clauses. @@ -1057,9 +1083,9 @@ async traceEvent(request: SessionEventTraceRequest): Promise async readEvent(request: SessionEventReadRequest): Promise ``` -Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) +Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:73`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:74`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` @@ -1701,7 +1727,7 @@ The concrete provider retains pi-tui, focus, and terminal lifecycle state. Plugi abstract openOverlay(request: TuiOverlayRequest): TuiOverlaySession ``` -Source: [`packages/ui/tui/src/index.ts:132`](../../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:150`](../../packages/ui/tui/src/index.ts) ## `ctx.userInteraction` — `UserInteractionService` diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index f45eb0417a..ffd4304376 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -34,6 +34,18 @@ interface SessionLocation { } ``` +## `SessionLiveLease` — live ownership capability + +`claimLive(id)` returns one idempotent release capability. The base service tracks only its own process; first-party backends additionally reject another live process and reclaim a dead owner's lease. `isLive(id)` reports either local or backend ownership without claiming it. + +```ts type-equiv +/** Idempotent capability releasing one acquired live-session lease reference. */ +interface SessionLiveLease { + /** Release this caller's lease reference after its live session reaches quiescence. */ + release(): Promise +} +``` + ## `SessionHeader` — metadata beside the log Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`. diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 0fe1596aaf..25315e3166 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -25,7 +25,17 @@ interface SessionRecord { } ``` -`SessionSurfaceSnapshot` is one exact-read observation rather than a retained subscription. Its raw-log boundary and folded events come from the same live-preferred load. +`SessionLogSnapshot` is the complete detached, replay-validated raw log used by resume preflight. `SessionSurfaceSnapshot` is one exact-read surface observation rather than a retained subscription. + +```ts type-equiv +/** One validated detached observation of a logical session's complete raw log. */ +interface SessionLogSnapshot { + /** Cloned session header selected from the same observation as `events`. */ + session: SessionHeader + /** Cloned contiguous raw events after persistence repair and replay validation. */ + events: SessionEvent[] +} +``` ```ts type-equiv /** One atomic live-preferred observation of a session's current model surface. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 179ad0dcee..7f7ecc4f2d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:371`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 9522fb4979..032a82bdaf 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -27,7 +27,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio dsh --resume ``` -The TUI prints this exact command on exit and lists it under `/resume`, so resuming is copy-paste. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID= pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. +`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then atomically replaces the process with `dsh --resume `; the terminal never has two owners. The TUI still prints that command on exit and shows it when a custom host cannot hand off. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID= pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. ## Code Mode diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index e4548da79e..4668747077 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -29,6 +29,8 @@ # The smoke's log inspection reads plain `.jsonl`; keep the scripted # fixture uncompressed like the other snapshot-facing configs. persistenceCompression: none + resumeSessionId: !!js process.env.RESUME_SESSION_ID + resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 welcome: 'scripted TUI ready.' diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 66697673ba..9c198870ad 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -1,8 +1,10 @@ -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' +import { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' +import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts' import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) @@ -44,6 +46,31 @@ function seedWorkspace( } } +/** Seed one real plaintext JSONL session for the `/resume` selector and host handoff smoke. */ +async function seedResumeSession(cwd: string): Promise { + const sessionCwd = await realpath(cwd) + const id = SessionId('resume-target') + const meta: SessionHeader = { version: 0, id, createdAt: 1_700_000_000_000, cwd: sessionCwd } + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1_700_000_000_001, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: 1_700_000_000_002, data: { content: [{ type: 'text', text: 'persisted prompt' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: 1_700_000_000_003, data: { turn: 1, step: 1 } }, + { type: 'request/header', seq: 3, time: 1_700_000_000_004, data: { header: { config: { provider: 'tui-scripted', model: 'tui-scripted-model' } }, reason: 'initial' } }, + { type: 'assistant/message', seq: 4, time: 1_700_000_000_005, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'persisted answer' }], provenance: { provider: 'tui-scripted', model: 'tui-scripted-model' } }, surfaceOp: 'append' }, + { type: 'step/end', seq: 5, time: 1_700_000_000_006, data: { turn: 1, step: 1 } }, + { type: 'session/title', seq: 6, time: 1_700_000_000_007, data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } }, + { type: 'todo/write', seq: 7, time: 1_700_000_000_008, data: { todos: [{ content: 'Preserve restored state', status: 'in_progress' }] } }, + { type: 'turn/end', seq: 8, time: 1_700_000_000_009, data: { turn: 1, reason: { kind: 'completed' } } }, + ] + const file = logPath(join(cwd, '.sessions'), sessionCwd, id, 'none') + await mkdir(dirname(file), { recursive: true }) + await writeFile(file, [ + JSON.stringify(toHeaderLine(meta)), + ...events.map(event => JSON.stringify(event)), + '', + ].join('\n')) +} + /** The rendered system prompt from the first `request/header` in the workspace's persisted session log. */ async function readLoggedSystemPrompt(cwd: string): Promise { const sessionsDir = join(cwd, '.sessions') @@ -233,6 +260,27 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { }) describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { + it('hands /resume to one exec-replaced terminal owner and restores the same session state', async () => { + const output = await smoke({ + label: 'dsh in-place resume', + tempDirPrefix: 'dsh-in-place-resume-', + binScript: dshBinScript, + configArgs: [scriptedConfigPath], + prepare: seedResumeSession, + actions: [ + { waitFor: 'scripted TUI ready.', send: '/resume\r' }, + { waitFor: 'Resume selector design', send: 'Resume selector design' }, + { waitFor: 'Search: Resume selector design', send: '\r' }, + { waitFor: 'Preserve restored state', send: '/exit\r' }, + ], + }) + const released = output.indexOf('\u001B[?2004l') + const restored = output.indexOf('Resume selector design — DeepSeek Harness') + expect(released).toBeGreaterThanOrEqual(0) + expect(restored).toBeGreaterThan(released) + expect(output).toContain('Preserve restored state') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('boots the shipped default config with no arguments and no personal overlay', async () => { const output = await smoke({ label: 'dsh default boot', diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ffc1670f4e..f49cc75b1c 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -480,6 +480,14 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract listSnapshots(): Promise', jsDoc: '/**\n * List materialized sessions with cheap per-log change tokens.\n *\n * Repeated observations of an unchanged log return the same revision. A\n * successful mutating {@link load} repair changes the next listed revision.\n * Revisions also distinguish independently backed stores so backend-local\n * counters cannot compare equal across different persistence sources.\n * @returns one header and opaque revision per materialized session without loading full logs.\n */', }, + { + signature: 'claimLive(id: SessionId): Promise', + jsDoc: '/**\n * Atomically acquire this process\'s live ownership of a session id.\n * Reentrant claims share one backend lease. First-party backends override\n * this process-local fallback to reject another live process and reclaim a\n * dead owner.\n * @param id - session identity that is about to become live.\n * @returns a single-release reference owned by the caller.\n */', + }, + { + signature: 'isLive(id: SessionId): Promise', + jsDoc: '/**\n * Check whether any process currently owns a live lease for this session.\n * The base implementation reports only claims on this service instance.\n * @param id - persisted or prospective session identity.\n * @returns true while a non-stale lease exists, including this process\'s lease.\n */', + }, ], }, { @@ -498,6 +506,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'listSessions(): Promise', jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @returns deterministic newest-first cloned session records.\n */', }, + { + signature: 'async readSession(sessionId: SessionId): Promise', + jsDoc: '/**\n * Read and replay-validate one complete logical session log without making it live.\n * @param sessionId - live or persisted session id to read.\n * @returns cloned header and complete raw event log from one observation.\n * @throws when persistence, header compatibility, or replay validation fails.\n */', + }, { signature: 'async filterSessions(filters: readonly SessionResultFilter[]): Promise', jsDoc: '/**\n * Filter the complete logical corpus with provider-independent predicates.\n * @param filters - ANDed session metadata and availability clauses.\n * @returns matching cloned records in deterministic newest-first order.\n */', @@ -1805,10 +1817,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionLineageTrace', declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});', }, + { + name: 'SessionLiveLease', + declaration: 'export interface SessionLiveLease {\n release(): Promise;\n}', + }, { name: 'SessionLocation', declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', }, + { + name: 'SessionLogSnapshot', + declaration: 'export interface SessionLogSnapshot {\n session: SessionHeader;\n events: SessionEvent[];\n}', + }, { name: 'SessionPersistenceRevision', declaration: 'export type SessionPersistenceRevision = Branded<\'SessionPersistenceRevision\'>;', diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index bdaa3f2401..15ece492e3 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -25,7 +25,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' -import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { SessionLiveLease, SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { bindReactLoopAgentContext, prepareReactLoopAgent, @@ -114,6 +114,7 @@ class AgentCreationTransaction { private scope: Scope | undefined private session: Session | undefined private lifecycleDispose: (() => Promise | void) | undefined + private liveLease: SessionLiveLease | undefined private detachSession: (() => void) | undefined private detachAgent: (() => void) | undefined private publishing = false @@ -186,6 +187,12 @@ class AgentCreationTransaction { ]) } + /** Retain a pre-load persistence lease until this transaction fully tears down. */ + holdLiveLease(lease: SessionLiveLease): void { + this.assertActive() + this.liveLease = lease + } + /** Construct the driver and scope, then install their complete ordered lifecycle. */ prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent { this.assertActive() @@ -219,6 +226,11 @@ class AgentCreationTransaction { // First yielded, disposed last. yield () => { this.finish() } yield scope.rawDispose + yield async () => { + const lease = this.liveLease + this.liveLease = undefined + await lease?.release() + } yield () => { this.detachSession?.() this.detachSession = undefined @@ -315,7 +327,13 @@ class AgentCreationTransaction { try { await this.scope?.dispose() } finally { - this.finish() + try { + const lease = this.liveLease + this.liveLease = undefined + await lease?.release() + } finally { + this.finish() + } } } })()) @@ -607,6 +625,15 @@ export class AgentLoop extends Service implements AgentFactory { options.signal, ) try { + const claiming = persistence.claimLive(options.resumeSessionId) + let lease: SessionLiveLease + try { + lease = await transaction.waitFor(claiming) + } catch (error) { + void claiming.then(claim => claim.release(), () => {}) + throw error + } + transaction.holdLiveLease(lease) const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId)) transaction.assertActive() const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, { diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index fdf5c39514..2d19db7a0d 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -391,6 +391,51 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) + it('owner unload during live-lease acquisition releases a late claim', async () => { + const sessionId = SessionId('resume-claim-owner-unload') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + const claiming = Promise.withResolvers>>() + const claimStarted = Promise.withResolvers() + const originalClaim = ctx.sessionPersistence.claimLive.bind(ctx.sessionPersistence) + ctx.sessionPersistence.claimLive = (id) => { + expect(id).toBe(sessionId) + claimStarted.resolve(undefined) + return claiming.promise + } + + let resuming!: ReturnType + const owner = await ctx.plugin(Object.assign((inner: Context) => { + resuming = inner.agents.resume({ resumeSessionId: sessionId }) + }, { inject: ['agents'] })) + await claimStarted.promise + const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/) + await promptly(owner.dispose()) + await rejection + + let releases = 0 + claiming.resolve({ release: () => { releases += 1; return Promise.resolve() } }) + await Promise.resolve() + await Promise.resolve() + expect(releases).toBe(1) + ctx.sessionPersistence.claimLive = originalClaim + await ctx.fiber.dispose() + }) + + it('propagates a rejected live-lease claim without loading or publishing', async () => { + const sessionId = SessionId('resume-claim-rejected') + const root = await persistSession(sessionId) + const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) + let loads = 0 + ctx.sessionPersistence.claimLive = () => Promise.reject(new Error('occupied elsewhere')) + ctx.sessionPersistence.load = () => { loads += 1; return Promise.reject(new Error('must not load')) } + await expect(ctx.agents.resume({ resumeSessionId: sessionId })) + .rejects.toThrow('occupied elsewhere') + expect(loads).toBe(0) + expect(ctx.agents.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) + it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => { const sessionId = SessionId('resume-load-factory-unload') const root = await persistSession(sessionId) diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index a20a0fae9d..bba7ae7f7e 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -41,10 +41,11 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` | | `welcome` | `ready.` | TUI subtitle | +| `resumeCommand` | — | Exit and no-host fallback command template; the selector itself uses session query and host handoff | | `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height | | `resumeSessionId` | — | Exact persisted session to resume | -Fresh runs mint a `main-session-` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. +Fresh runs mint a `main-session-` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for safe in-place process handoff. ## The bin diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index bf86bf8633..4aaf30f45a 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -6,6 +6,9 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ``` / + .live/ + .lock # PID + nonce cross-process live lease + .lock.reclaim # ephemeral stale-owner takeover guard cwd-/ # per-project bucket (or _no-cwd/ when no cwd) .jsonl.zstd # default: checksummed header frame + append frames .jsonl # only with compression: 'none' @@ -43,7 +46,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the ## Write path -The plugin copies frozen session events into one controller per live session and starts an eager drain. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. The owning backend instance serializes operations for one session; disposal drains every retained controller before teardown. +The plugin copies frozen session events into one controller per live session and starts an eager drain. Before a session can flush or resume, the coordinator claims an exclusive `.live/.lock` containing the process PID and an exec-stable nonce; another live process is rejected, while a dead owner is reclaimed under the separate `.reclaim` guard. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. Disposal drains every retained controller before releasing its lease. ## Model Experience @@ -66,5 +69,6 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr - **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). -- **One live writer per session** — append and repair are coordinated only inside the owning backend instance. Another backend instance or process must not write the same session until that owner reaches quiescent disposal; initial same-id publication remains collision-safe through the POSIX no-overwrite hard link or Windows write-through rename without replacement. +- **Lease scope is local-host advisory ownership** — PID liveness prevents two ordinary local Harness processes from resuming the same id, but it is not a distributed lease for shared network filesystems or hostile principals. +- **A crash during stale-lease takeover fails closed** — if the reclaiming process itself crashes while holding the short-lived `.reclaim` guard, an operator must remove that guard after confirming no recovery is active. - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 629c0e3ff1..a3ed611a4c 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -14,8 +14,9 @@ import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type StoredPrefix, + sessionLeaseProcessIsLive, shareSessionLiveLease, + type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, + type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -64,6 +65,11 @@ interface JsonlTornMarker { recoveredEvents: SessionEvent[] } +interface JsonlLiveLeaseRecord { + pid: number + nonce: string +} + /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' @@ -135,6 +141,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.inspect(id) } + override claimLive(id: SessionId): Promise { + return this.coordinator.claimLive(id) + } + + override isLive(id: SessionId): Promise { + return this.coordinator.isLive(id) + } + // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. @@ -274,6 +288,110 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return snapshots } + /** Atomically publish one process lease, reclaiming a crashed owner's record. */ + async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise> { + const path = this.liveLeasePath(id) + return shareSessionLiveLease(`jsonl:${path}`, () => this.acquireLiveFile(path, id, owner)) + } + + private async acquireLiveFile( + path: string, + id: SessionId, + owner: SessionLiveOwner, + ): Promise<() => Promise> { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + for (;;) { + try { + const handle = await open(path, 'wx', 0o600) + try { + await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + const current = await this.readLiveLease(path) + if (current !== undefined && current.pid === owner.pid && current.nonce === owner.nonce) break + if (current === undefined || sessionLeaseProcessIsLive(current.pid)) { + throw new Error(`session "${id}" is occupied by another live process`) + } + const reclaimPath = `${path}.reclaim` + let reclaim: Awaited> + try { + reclaim = await open(reclaimPath, 'wx', 0o600) + } catch (reclaimError) { + /* v8 ignore else -- non-contention filesystem failures are propagated verbatim and are not portable to induce */ + if ((reclaimError as NodeJS.ErrnoException).code === 'EEXIST') { + throw new Error(`session "${id}" live-lease reclamation is already in progress`) + } + /* v8 ignore next -- non-contention filesystem failures are propagated verbatim and are not portable to induce */ + throw reclaimError + } + try { + /* v8 ignore start -- cross-process revalidation is covered by the two-process race test */ + const latest = await this.readLiveLease(path) + if (latest === undefined) { + if (await this.exists(path)) throw new Error(`session "${id}" has an unreadable live-process lease`) + } else if (latest.pid !== owner.pid || latest.nonce !== owner.nonce) { + if (sessionLeaseProcessIsLive(latest.pid)) { + throw new Error(`session "${id}" is occupied by another live process`) + } + await rm(path, { force: true }) + } + /* v8 ignore stop */ + } finally { + try { + await reclaim.close() + } finally { + await rm(reclaimPath, { force: true }) + } + } + } + } + return async () => { + const current = await this.readLiveLease(path) + if (current?.pid === owner.pid && current.nonce === owner.nonce) await rm(path, { force: true }) + } + } + + /** Report one non-stale process lease and clean up a crashed owner's record. */ + async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise { + const path = this.liveLeasePath(id) + const current = await this.readLiveLease(path) + if (current === undefined) return await this.exists(path) + if (current.pid === owner.pid && current.nonce === owner.nonce) return true + if (sessionLeaseProcessIsLive(current.pid)) return true + return false + } + + private liveLeasePath(id: SessionId): string { + return join(this.root, '.live', `${encodeSegment(id)}.lock`) + } + + private async readLiveLease(path: string): Promise { + let text: string + try { + text = await readFile(path, 'utf8') + } catch (error) { + if (isENOENT(error)) return undefined + throw error + } + let value: unknown + try { + value = JSON.parse(text) + } catch { + return undefined + } + if (typeof value !== 'object' || value === null + || !Number.isSafeInteger((value as { pid?: unknown }).pid) + || (value as { pid: number }).pid <= 0 + || typeof (value as { nonce?: unknown }).nonce !== 'string' + || (value as { nonce: string }).nonce.length === 0) return undefined + return value as JsonlLiveLeaseRecord + } + private async listArtifacts(): Promise> { await this.ensureRootEncoding() const artifacts: Array<{ header: SessionHeader; path: string }> = [] diff --git a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts new file mode 100644 index 0000000000..2a42b8c402 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts @@ -0,0 +1,16 @@ +/** Child process that holds one JSONL live-session lease until it is killed. */ + +import { writeFile } from 'node:fs/promises' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' + +const [root, marker] = process.argv.slice(2) +if (root === undefined || marker === undefined) throw new Error('usage: live-lease-child.ts ') + +const ctx = new Context() +await ctx.plugin(SessionStore) +await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) +await ctx.sessionPersistence.claimLive(SessionId('leased-session')) +await writeFile(marker, 'held') +await new Promise(() => { setInterval(() => {}, 60_000) }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts new file mode 100644 index 0000000000..b4ef3b6e59 --- /dev/null +++ b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts @@ -0,0 +1,33 @@ +/** Child process competing to reclaim one stale JSONL live-session lease. */ + +import { access, writeFile } from 'node:fs/promises' +import { Context } from 'cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' + +const [root, gate, marker, rawId] = process.argv.slice(2) +if (root === undefined || gate === undefined || marker === undefined || rawId === undefined) { + throw new Error('usage: live-lease-race-child.ts ') +} + +for (;;) { + try { + await access(gate) + break + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +const ctx = new Context() +await ctx.plugin(SessionStore) +await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) +try { + await ctx.sessionPersistence.claimLive(SessionId(rawId)) + await writeFile(marker, 'claimed') + await new Promise(() => { setInterval(() => {}, 60_000) }) +} catch (error) { + await writeFile(marker, `rejected:${error instanceof Error ? error.message : String(error)}`) + await ctx.fiber.dispose() +} diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 2b49b7d55b..517cf7bb04 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1,17 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawn } from 'node:child_process' import { Context } from 'cordis' -import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' +import { access, appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { isAbsolute, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence' import { encodeSegment, eventLines, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' let root: string const dirs: string[] = [] +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const leaseChild = fileURLToPath(new URL('./fixtures/live-lease-child.ts', import.meta.url)) +const leaseRaceChild = fileURLToPath(new URL('./fixtures/live-lease-race-child.ts', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] } @@ -142,6 +149,179 @@ describe('SessionPersistenceJsonl: format helpers', () => { }) }) +describe('SessionPersistenceJsonl: cross-process live leases', () => { + it('reference-counts one physical lease across backend instances in the process', async () => { + const dir = await freshRoot() + const contexts = [new Context(), new Context()] + for (const ctx of contexts) { + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) + } + try { + const first = await contexts[0]!.sessionPersistence.claimLive(SessionId('shared-live')) + const second = await contexts[1]!.sessionPersistence.claimLive(SessionId('shared-live')) + await first.release() + await expect(contexts[1]!.sessionPersistence.isLive(SessionId('shared-live'))).resolves.toBe(true) + await second.release() + await expect(contexts[1]!.sessionPersistence.isLive(SessionId('shared-live'))).resolves.toBe(false) + } finally { + await Promise.all(contexts.map(ctx => ctx.fiber.dispose())) + } + }) + + it('disables another live owner and reclaims its lease after the process exits', async () => { + const dir = await freshRoot() + const marker = join(dir, 'lease-held') + const child = spawn(process.execPath, ['--import', tsxLoader, leaseChild, dir, marker], { + cwd: repoRoot, + env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, + stdio: ['ignore', 'ignore', 'pipe'], + }) + let stderr = '' + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + try { + await vi.waitFor(() => access(marker), { timeout: 30_000 }) + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) + try { + await expect(ctx.sessionPersistence.isLive(SessionId('leased-session'))).resolves.toBe(true) + await expect(ctx.sessionPersistence.claimLive(SessionId('leased-session'))) + .rejects.toThrow('occupied by another live process') + const closed = new Promise(resolve => child.once('close', () => { resolve() })) + child.kill() + await closed + await expect(ctx.sessionPersistence.isLive(SessionId('leased-session'))).resolves.toBe(false) + const leasePath = join(dir, '.live', `${encodeSegment('leased-session')}.lock`) + await writeFile(leasePath, `${JSON.stringify({ pid: child.pid, nonce: 'dead-owner' })}\n`) + const claim = await ctx.sessionPersistence.claimLive(SessionId('leased-session')) + await claim.release() + } finally { + await ctx.fiber.dispose() + } + } catch (error) { + throw new Error(`live-lease child failed: ${stderr}`, { cause: error }) + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill() + } + }, 40_000) + + it('fails closed on malformed lease records and surfaces lease read errors', async () => { + const dir = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) + const liveDir = join(dir, '.live') + await mkdir(liveDir, { recursive: true }) + try { + const malformed = [ + 'not json', + JSON.stringify(null), + JSON.stringify({ pid: 1.5, nonce: 'x' }), + JSON.stringify({ pid: 0, nonce: 'x' }), + JSON.stringify({ pid: process.pid, nonce: 1 }), + JSON.stringify({ pid: process.pid, nonce: '' }), + ] + for (const [index, content] of malformed.entries()) { + const id = SessionId(`malformed-${index}`) + const path = join(liveDir, `${encodeSegment(id)}.lock`) + await writeFile(path, content) + await expect(ctx.sessionPersistence.isLive(id)).resolves.toBe(true) + await expect(ctx.sessionPersistence.claimLive(id)).rejects.toThrow('occupied by another live process') + } + + const unreadable = SessionId('unreadable-lease') + await mkdir(join(liveDir, `${encodeSegment(unreadable)}.lock`)) + await expect(ctx.sessionPersistence.isLive(unreadable)).rejects.toThrow() + + const replaced = SessionId('replaced-release') + const claim = await ctx.sessionPersistence.claimLive(replaced) + const replacedPath = join(liveDir, `${encodeSegment(replaced)}.lock`) + await writeFile(replacedPath, JSON.stringify({ pid: process.pid, nonce: 'replacement' })) + await claim.release() + expect(await readFile(replacedPath, 'utf8')).toContain('replacement') + + const inherited = SessionId('inherited-owner') + const inheritedPath = join(liveDir, `${encodeSegment(inherited)}.lock`) + await writeFile(inheritedPath, JSON.stringify(sessionLiveOwner())) + await expect(ctx.sessionPersistence.isLive(inherited)).resolves.toBe(true) + const inheritedClaim = await ctx.sessionPersistence.claimLive(inherited) + await inheritedClaim.release() + + await expect(ctx.sessionPersistence.claimLive(SessionId('x'.repeat(300)))) + .rejects.toThrow() + + const guarded = SessionId('guarded-reclaim') + const guardedPath = join(liveDir, `${encodeSegment(guarded)}.lock`) + await writeFile(guardedPath, JSON.stringify({ pid: 2_147_483_647, nonce: 'dead-owner' })) + await writeFile(`${guardedPath}.reclaim`, 'busy') + await expect(ctx.sessionPersistence.claimLive(guarded)) + .rejects.toThrow('reclamation is already in progress') + } finally { + await ctx.fiber.dispose() + } + }) + + it('allows exactly one process to reclaim a stale lease', async () => { + const dir = await freshRoot() + const liveDir = join(dir, '.live') + await mkdir(liveDir, { recursive: true }) + const sessionId = SessionId('reclaim-race') + await writeFile( + join(liveDir, `${encodeSegment(sessionId)}.lock`), + JSON.stringify({ pid: 2_147_483_647, nonce: 'dead-owner' }), + ) + const gate = join(dir, 'race-start') + const markers = [join(dir, 'race-a'), join(dir, 'race-b')] + const children = markers.map(marker => spawn( + process.execPath, + ['--import', tsxLoader, leaseRaceChild, dir, gate, marker, sessionId], + { + cwd: repoRoot, + env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, + stdio: ['ignore', 'ignore', 'pipe'], + }, + )) + const errors = ['', ''] + children.forEach((child, index) => { + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { errors[index] = (errors[index] ?? '') + chunk }) + }) + try { + await writeFile(gate, 'go') + await vi.waitFor(() => Promise.all(markers.map(marker => access(marker))), { timeout: 30_000 }) + const outcomes = await Promise.all(markers.map(marker => readFile(marker, 'utf8'))) + expect(outcomes.filter(outcome => outcome === 'claimed')).toHaveLength(1) + expect(outcomes.filter(outcome => outcome.startsWith('rejected:'))).toHaveLength(1) + + const winner = children[outcomes.findIndex(outcome => outcome === 'claimed')]! + const loser = children[outcomes.findIndex(outcome => outcome.startsWith('rejected:'))]! + if (loser.exitCode === null && loser.signalCode === null) { + await new Promise(resolve => loser.once('close', () => { resolve() })) + } + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) + try { + await expect(ctx.sessionPersistence.claimLive(sessionId)) + .rejects.toThrow('occupied by another live process') + } finally { + await ctx.fiber.dispose() + } + const closed = new Promise(resolve => winner.once('close', () => { resolve() })) + winner.kill() + await closed + } catch (error) { + throw new Error(`live-lease race children failed: ${errors.join('\n')}`, { cause: error }) + } finally { + for (const child of children) { + if (child.exitCode === null && child.signalCode === null) child.kill() + } + } + }, 40_000) +}) + describe('SessionPersistenceJsonl: durability and crash semantics', () => { let ctx: Context beforeEach(async () => { diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index f1f4bc1f7b..374da93faa 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id, and `live_session_leases` stores one PID and exec-stable nonce per live session. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. @@ -33,7 +33,7 @@ interface Config { ## Write path -Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. +Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. A live lease is acquired in a `BEGIN IMMEDIATE` transaction before flush or resume and released after the exact lifecycle retires. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. ## Model Experience diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 5804c18282..4399ee9c90 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -15,8 +15,9 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type StoredPrefix, + sessionLeaseProcessIsLive, shareSessionLiveLease, + type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, + type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -161,6 +162,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.inspect(id) } + override claimLive(id: SessionId): Promise { + return this.coordinator.claimLive(id) + } + + override isLive(id: SessionId): Promise { + return this.coordinator.isLive(id) + } + // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. @@ -271,6 +280,55 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers })) } + /** Atomically acquire one SQLite-backed process lease. */ + async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise> { + await this.ready + return shareSessionLiveLease( + `sqlite:${this.storeIdentity}:${id}`, + () => Promise.resolve().then(() => this.acquireLiveRow(id, owner)), + ) + } + + private acquireLiveRow(id: SessionId, owner: SessionLiveOwner): () => Promise { + this.db.exec('BEGIN IMMEDIATE') + try { + const current = this.liveLeaseFor(id) + if (current !== undefined + && (current.pid !== owner.pid || current.nonce !== owner.nonce)) { + if (sessionLeaseProcessIsLive(current.pid)) { + throw new Error(`session "${id}" is occupied by another live process`) + } + this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id) + } + this.db.prepare(` + INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET pid = excluded.pid, nonce = excluded.nonce + `).run(id, owner.pid, owner.nonce) + this.db.exec('COMMIT') + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + return async () => { + await this.ready + this.db.prepare( + 'DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?', + ).run(id, owner.pid, owner.nonce) + } + } + + /** Report a non-stale SQLite lease and remove a crashed owner's row. */ + async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise { + await this.ready + const current = this.liveLeaseFor(id) + if (current === undefined) return false + if ((current.pid === owner.pid && current.nonce === owner.nonce) + || sessionLeaseProcessIsLive(current.pid)) return true + this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?') + .run(id, current.pid, current.nonce) + return false + } + /** Close the database handle (awaited by the coordinator's dispose, post-drain). */ async close(): Promise { await this.ready @@ -284,6 +342,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined } + private liveLeaseFor(id: SessionId): { pid: number; nonce: string } | undefined { + return this.db.prepare('SELECT pid, nonce FROM live_session_leases WHERE session_id = ?') + .get(id) as { pid: number; nonce: string } | undefined + } + /** * Insert-or-replace a session's metadata row. The only caller is the first * materializing `appendBatch`, so writing the row IS the materialization (its diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 8b8dcd78e0..6a5be76eb9 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 8 +export const SCHEMA_VERSION = 9 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -68,7 +68,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' * rather than being migrated in place. * @param path - the SQLite database file to open (created when absent). * @param journalMode - validated journal pragma. - * @returns the open handle with pragmas applied and all three tables ensured. + * @returns the open handle with pragmas applied and all tables ensured. */ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { const db = new DatabaseSync(path) @@ -128,6 +128,13 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM PRIMARY KEY (session_id, seq) ) STRICT `) + db.exec(` + CREATE TABLE IF NOT EXISTS live_session_leases ( + session_id TEXT PRIMARY KEY, + pid INTEGER NOT NULL, + nonce TEXT NOT NULL + ) STRICT + `) } /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 3976e71549..a3aba22323 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -7,6 +7,7 @@ import { dirname, join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' +import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence' import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' @@ -442,7 +443,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(8) + expect(SCHEMA_VERSION).toBe(9) }) it('keeps the revision stable for an empty repair hook', async () => { @@ -458,6 +459,38 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { + it('claims, rejects, reclaims, inspects, and releases SQLite live leases', async () => { + const path = await freshDbPath() + const b = await backend(path) + await b.ctx.sessionPersistence.list() + const concrete = b.ctx.sessionPersistence as SessionPersistenceSqlite + const owner = sessionLiveOwner() + const db = openDatabase(path, 'wal') + const insert = db.prepare('INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)') + insert.run('occupied-lease', process.pid, 'another-owner') + insert.run('stale-claim', 2_147_483_647, 'dead-owner') + insert.run('stale-inspect', 2_147_483_647, 'dead-owner') + insert.run('owned-inspect', owner.pid, owner.nonce) + db.close() + + await expect(concrete.acquireLive(SessionId('occupied-lease'), owner)) + .rejects.toThrow('occupied by another live process') + const claim = await concrete.acquireLive(SessionId('stale-claim'), owner) + expect(await concrete.inspectLive(SessionId('owned-inspect'), owner)).toBe(true) + expect(await concrete.inspectLive(SessionId('stale-inspect'), owner)).toBe(false) + expect(await concrete.inspectLive(SessionId('missing-inspect'), owner)).toBe(false) + await claim() + await b.dispose() + + const memory = new Context() + await memory.plugin(SessionStore) + await memory.plugin(SessionPersistenceSqlite, { path: ':memory:' }) + const memoryClaim = await memory.sessionPersistence.claimLive(SessionId('memory-live')) + expect(await memory.sessionPersistence.isLive(SessionId('memory-live'))).toBe(true) + await memoryClaim.release() + await memory.fiber.dispose() + }) + it('rejects and closes a current-schema database with an invalid store identity', async () => { const path = await freshDbPath() const db = openDatabase(path, 'wal') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 25429bd720..2bfa3a31b1 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -15,6 +15,10 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `inspect(id): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; intended for read models and other observers that must never recover a log. | | `list(): Promise` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. | +| `claimLive(id): Promise` | Atomically claim live ownership. First-party backends reject another live process and reclaim a dead owner; release follows quiescence. | +| `isLive(id): Promise` | Report a current non-stale live lease, including one owned by this process. | + +The abstract base supplies a process-local fallback for lightweight third-party implementations. A backend that needs multi-process safety overrides both live-lease methods. ## Invariants every backend must honor @@ -31,7 +35,7 @@ Each `session/event` copies its event into the session controller and starts an Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. -When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. +When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state and the backend-owned live lease for that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, releases their leases, awaits per-id operations, and only then closes the storage handle. The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. @@ -44,6 +48,8 @@ The `PersistenceBackend` hooks (the only seam between the coordinato | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | | `list()` | List all stored metadata. | +| `acquireLive?(id, owner)` | Atomically acquire a backend-owned cross-process lease and return its physical release. | +| `inspectLive?(id, owner)` | Report or reclaim a backend-owned lease without acquiring it. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path validates and clones the prefix without calling `commitRepair` or publishing write state. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index fb46aa4877..8ea1790b3e 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -8,6 +8,8 @@ import { Context } from 'cordis' import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import { sessionLiveOwner } from './lease.ts' +import type { SessionLiveLease, SessionLiveOwner } from './lease.ts' /** * A stored session's header, valid contiguous event prefix, and optional opaque @@ -63,6 +65,12 @@ export interface PersistenceBackend { /** List all stored (materialized) sessions' metadata. */ list(): Promise + /** Optionally acquire a backend-owned cross-process live-session lease. */ + acquireLive?(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise> + + /** Optionally inspect and reclaim a backend-owned live-session lease. */ + inspectLive?(id: SessionId, owner: SessionLiveOwner): Promise + /** * Optional lifecycle teardown (e.g. close a database handle). Awaited by the * coordinator's dispose effect AFTER the quiescence drain. A stateless file @@ -96,6 +104,7 @@ interface LiveSessionState { pending: SessionEvent[] init: Promise flush: Promise | undefined + lease?: SessionLiveLease } /** Collect the rejection reasons from a set of promises (none-throwing). */ @@ -161,6 +170,12 @@ export class PersistenceCoordinator { * same id, so writes for one session never interleave. Keyed by session id. */ private chains = new Map>() + /** One backend lease with process-local reference counting per session id. */ + private liveClaims = new Map Promise + }>() + private readonly liveOwner = sessionLiveOwner() constructor(private ctx: Context, private backend: PersistenceBackend) { this.installWritePath() @@ -273,6 +288,61 @@ export class PersistenceCoordinator { return this.serialize(id, () => this.inspectCore(id)) } + /** + * Acquire one process-local reference to the backend's cross-process lease. + * @param id - session identity about to become live. + * @returns one idempotent release capability. + */ + async claimLive(id: SessionId): Promise { + const acquireLive = this.backend.acquireLive?.bind(this.backend) + if (acquireLive === undefined) return { release: () => Promise.resolve() } + await this.serialize(id, async () => { + const existing = this.liveClaims.get(id) + if (existing !== undefined) { + existing.refs += 1 + return + } + const releaseBackend = await acquireLive(id, this.liveOwner) + this.liveClaims.set(id, { refs: 1, releaseBackend }) + }) + let releaseTask: Promise | undefined + return { + release: () => { + if (releaseTask !== undefined) return releaseTask + const task = this.serialize(id, async () => { + const claim = this.liveClaims.get(id) + /* v8 ignore next -- this capability is returned only after its claim enters the serialized map */ + if (claim === undefined) return + claim.refs -= 1 + if (claim.refs > 0) return + try { + await claim.releaseBackend() + } catch (error) { + claim.refs += 1 + throw error + } + this.liveClaims.delete(id) + }) + const wrapped = task.catch((error: unknown) => { + releaseTask = undefined + throw error + }) + releaseTask = wrapped + return wrapped + }, + } + } + + /** + * Check the backend's current cross-process lease state. + * @param id - session identity to inspect. + * @returns whether this or another live process owns the session. + */ + isLive(id: SessionId): Promise { + if (this.liveClaims.has(id)) return Promise.resolve(true) + return this.backend.inspectLive?.(id, this.liveOwner) ?? Promise.resolve(false) + } + private async inspectCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const stored = await this.backend.loadStored(id) if (stored === undefined) throw new Error(`session "${id}" not found`) @@ -382,6 +452,9 @@ export class PersistenceCoordinator { let disposeError: unknown try { const errors = await settledErrors([...this.live.keys()].map(session => this.flush(session))) + errors.push(...await settledErrors( + [...this.live.values()].flatMap(live => live.lease === undefined ? [] : [live.lease.release()]), + )) while (this.chains.size > 0) await Promise.allSettled([...this.chains.values()]) if (errors.length > 0) { throw new AggregateError(errors, `${this.backend.name} dispose failed`) @@ -441,6 +514,8 @@ export class PersistenceCoordinator { private async retireCore(session: Session): Promise { await this.flush(session) const id = session.header.id + const live = this.live.get(session) + await live?.lease?.release() await this.serialize(id, () => { this.live.delete(session) if (this.states.get(id)?.owner === session) this.states.delete(id) @@ -454,7 +529,16 @@ export class PersistenceCoordinator { const seed = session.events.map(e => structuredClone(e)) const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined } this.live.set(session, live) - live.init = this.serialize(session.header.id, () => this.onCreated(session, seed)) + live.init = this.claimLive(session.id).then(async (lease) => { + live.lease = lease + try { + await this.serialize(session.header.id, () => this.onCreated(session, seed)) + } catch (error) { + delete live.lease + await lease.release() + throw error + } + }) live.init.catch(() => { /* observed by flush/dispose through the controller */ }) return live } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index c785c9354c..3aa602c8c0 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -8,10 +8,13 @@ import { Context, Service } from 'cordis' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' +import type { SessionLiveLease } from './lease.ts' // Re-export the metadata vocabulary so consumers import it from the seam. export type { SessionHeader } from '@deepseek-ai/dsh-session' export { SessionPersistenceRevision } from './revision.ts' +export { sessionLeaseProcessIsLive, sessionLiveOwner, shareSessionLiveLease } from './lease.ts' +export type { SessionLiveLease, SessionLiveOwner } from './lease.ts' /** Lightweight immutable source identity returned without loading a full log. */ export interface SessionPersistenceSnapshot { @@ -50,6 +53,8 @@ export interface SessionLocation { * rewriting committed events. */ export abstract class SessionPersistence extends Service { + private readonly localLiveClaims = new Map() + constructor(ctx: Context) { super(ctx, 'sessionPersistence') } @@ -123,6 +128,39 @@ export abstract class SessionPersistence extends Service { * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise + + /** + * Atomically acquire this process's live ownership of a session id. + * Reentrant claims share one backend lease. First-party backends override + * this process-local fallback to reject another live process and reclaim a + * dead owner. + * @param id - session identity that is about to become live. + * @returns a single-release reference owned by the caller. + */ + claimLive(id: SessionId): Promise { + this.localLiveClaims.set(id, (this.localLiveClaims.get(id) ?? 0) + 1) + let released = false + return Promise.resolve({ + release: () => { + if (released) return Promise.resolve() + released = true + const refs = this.localLiveClaims.get(id) as number + if (refs <= 1) this.localLiveClaims.delete(id) + else this.localLiveClaims.set(id, refs - 1) + return Promise.resolve() + }, + }) + } + + /** + * Check whether any process currently owns a live lease for this session. + * The base implementation reports only claims on this service instance. + * @param id - persisted or prospective session identity. + * @returns true while a non-stale lease exists, including this process's lease. + */ + isLive(id: SessionId): Promise { + return Promise.resolve(this.localLiveClaims.has(id)) + } } export default SessionPersistence diff --git a/packages/session-persistence/session-persistence/src/lease.ts b/packages/session-persistence/session-persistence/src/lease.ts new file mode 100644 index 0000000000..5cc51117ab --- /dev/null +++ b/packages/session-persistence/session-persistence/src/lease.ts @@ -0,0 +1,98 @@ +/** Process-backed identity helpers for cross-process live-session leases. */ + +import { randomUUID } from 'node:crypto' + +const LIVE_OWNER_ENV = 'DSH_SESSION_LIVE_OWNER' + +/** Process identity stored in backend-owned cross-process live-session leases. */ +export interface SessionLiveOwner { + /** Operating-system process id; retained across an `execve` handoff. */ + readonly pid: number + /** Per-process-start nonce that distinguishes PID reuse. */ + readonly nonce: string +} + +/** Idempotent capability releasing one acquired live-session lease reference. */ +export interface SessionLiveLease { + /** Release this caller's lease reference after its live session reaches quiescence. */ + release(): Promise +} + +/** + * Stable owner inherited only by an exec-replaced process, not inferred from a session id. + * @returns this process's PID and exec-stable nonce. + */ +export function sessionLiveOwner(): SessionLiveOwner { + const nonce = process.env[LIVE_OWNER_ENV] ?? randomUUID() + process.env[LIVE_OWNER_ENV] = nonce + return { pid: process.pid, nonce } +} + +/** + * Whether a lease pid still names a process; permission denial counts as live. + * @param pid - positive operating-system process id from a lease record. + * @returns true unless the operating system reports that the process is absent. + */ +export function sessionLeaseProcessIsLive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + +interface SharedLeaseEntry { + refs: number + readonly acquired: Promise<() => Promise> +} + +const sharedLeases = new Map() + +/** + * Reference-count one physical lease across backend instances in this process. + * @param key - backend-kind plus canonical storage location and session id. + * @param acquire - single physical acquisition performed for the first reference. + * @returns an idempotent release for this caller's reference. + */ +export async function shareSessionLiveLease( + key: string, + acquire: () => Promise<() => Promise>, +): Promise<() => Promise> { + let entry = sharedLeases.get(key) + if (entry === undefined) { + entry = { refs: 0, acquired: acquire() } + sharedLeases.set(key, entry) + void entry.acquired.catch(() => { + /* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */ + if (sharedLeases.get(key) === entry) sharedLeases.delete(key) + }) + } + entry.refs += 1 + try { + await entry.acquired + } catch (error) { + entry.refs -= 1 + throw error + } + let releaseTask: Promise | undefined + return () => { + if (releaseTask !== undefined) return releaseTask + const task = (async () => { + entry.refs -= 1 + if (entry.refs > 0 || sharedLeases.get(key) !== entry) return + const release = await entry.acquired + await release() + /* v8 ignore next -- the entry remains installed until this exact final release succeeds */ + if (sharedLeases.get(key) === entry) sharedLeases.delete(key) + })() + const wrapped = task.catch((error: unknown) => { + entry.refs += 1 + /* v8 ignore next -- this closure is the sole writer of its releaseTask until settlement */ + if (releaseTask === wrapped) releaseTask = undefined + throw error + }) + releaseTask = wrapped + return wrapped + } +} diff --git a/packages/session-persistence/session-persistence/tests/lease.spec.ts b/packages/session-persistence/session-persistence/tests/lease.spec.ts new file mode 100644 index 0000000000..e35bba9311 --- /dev/null +++ b/packages/session-persistence/session-persistence/tests/lease.spec.ts @@ -0,0 +1,62 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { randomUUID } from 'node:crypto' +import { + sessionLeaseProcessIsLive, + sessionLiveOwner, + shareSessionLiveLease, +} from '../src/lease.ts' + +const originalOwner = process.env.DSH_SESSION_LIVE_OWNER + +afterEach(() => { + vi.restoreAllMocks() + if (originalOwner === undefined) delete process.env.DSH_SESSION_LIVE_OWNER + else process.env.DSH_SESSION_LIVE_OWNER = originalOwner +}) + +describe('process live-session lease helpers', () => { + it('creates one exec-stable owner identity and classifies process liveness', () => { + delete process.env.DSH_SESSION_LIVE_OWNER + const first = sessionLiveOwner() + expect(first.pid).toBe(process.pid) + expect(typeof first.nonce).toBe('string') + expect(sessionLiveOwner()).toEqual(first) + expect(sessionLeaseProcessIsLive(process.pid)).toBe(true) + + const missing = Object.assign(new Error('missing'), { code: 'ESRCH' }) + vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing }) + expect(sessionLeaseProcessIsLive(999_999)).toBe(false) + const denied = Object.assign(new Error('denied'), { code: 'EPERM' }) + vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw denied }) + expect(sessionLeaseProcessIsLive(999_998)).toBe(true) + }) + + it('shares one physical lease until every process-local reference releases', async () => { + const releasePhysical = vi.fn<() => Promise>(() => Promise.resolve()) + const acquire = vi.fn<() => Promise<() => Promise>>(() => Promise.resolve(releasePhysical)) + const key = `shared-${randomUUID()}` + const first = await shareSessionLiveLease(key, acquire) + const second = await shareSessionLiveLease(key, acquire) + expect(acquire).toHaveBeenCalledTimes(1) + await first() + expect(releasePhysical).not.toHaveBeenCalled() + await second() + await second() + expect(releasePhysical).toHaveBeenCalledTimes(1) + }) + + it('removes failed acquisitions and retries a failed physical release', async () => { + const key = `retry-${randomUUID()}` + await expect(shareSessionLiveLease(key, () => Promise.reject(new Error('claim failed')))) + .rejects.toThrow('claim failed') + + let releases = 0 + const release = await shareSessionLiveLease(key, () => Promise.resolve(async () => { + releases += 1 + if (releases === 1) throw new Error('release failed') + })) + await expect(release()).rejects.toThrow('release failed') + await expect(release()).resolves.toBeUndefined() + expect(releases).toBe(2) + }) +}) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 6b31d0843b..36192c37d7 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -4,7 +4,7 @@ import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, + type PersistenceBackend, type SessionLiveOwner, type SessionPersistenceSnapshot, type StoredPrefix, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' @@ -348,6 +348,46 @@ describe('PersistenceCoordinator stored identity', () => { }) }) +describe('PersistenceCoordinator live leases', () => { + it('degrades without backend hooks and retries a failed final release', async () => { + const fallbackCtx = new Context() + await fallbackCtx.plugin(SessionStore) + const fallback = new PersistenceCoordinator(fallbackCtx, new ControlledBackend()) + const fallbackClaim = await fallback.claimLive(SessionId('fallback-live')) + expect(await fallback.isLive(SessionId('fallback-live'))).toBe(false) + await fallbackClaim.release() + await fallbackCtx.fiber.dispose() + + class LeaseBackend extends ControlledBackend { + releaseAttempts = 0 + async acquireLive(_id: SessionId, _owner: SessionLiveOwner): Promise<() => Promise> { + return async () => { + this.releaseAttempts += 1 + if (this.releaseAttempts === 1) throw new Error('lease release failed') + } + } + inspectLive(): Promise { + return Promise.resolve(true) + } + } + + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new LeaseBackend() + const coordinator = new PersistenceCoordinator(ctx, backend) + const first = await coordinator.claimLive(SessionId('leased')) + const second = await coordinator.claimLive(SessionId('leased')) + expect(await coordinator.isLive(SessionId('leased'))).toBe(true) + await first.release() + await expect(second.release()).rejects.toThrow('lease release failed') + await expect(second.release()).resolves.toBeUndefined() + await expect(second.release()).resolves.toBeUndefined() + expect(backend.releaseAttempts).toBe(2) + expect(await coordinator.isLive(SessionId('leased'))).toBe(true) + await ctx.fiber.dispose() + }) +}) + describe('PersistenceCoordinator retirement', () => { it('a retiring unmaterialized owner without buffered events releases its id', async () => { const ctx = new Context() @@ -795,4 +835,20 @@ describe('SessionPersistence service registration', () => { await fiber.dispose() } }) + + it('provides a reference-counted process-local lease fallback', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemoryPersistence) + const id = SessionId('local-live') + const first = await ctx.sessionPersistence.claimLive(id) + const second = await ctx.sessionPersistence.claimLive(id) + expect(await ctx.sessionPersistence.isLive(id)).toBe(true) + await first.release() + await first.release() + expect(await ctx.sessionPersistence.isLive(id)).toBe(true) + await second.release() + expect(await ctx.sessionPersistence.isLive(id)).toBe(false) + await ctx.fiber.dispose() + }) }) diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index a83317ecf8..019eced081 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -5,6 +5,7 @@ ## Reads - `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. +- `readSession(sessionId)` returns one complete detached raw log after the same core replay validation used by resume; it never enters the session into the live store. - `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. - `readTitle(sessionId)` loads one live-preferred or persisted log and folds its latest `session/title` event into a `SessionTitleSnapshot`; it returns `undefined` when the known session has no title. diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 2028f908c1..826802e2b4 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -5,7 +5,7 @@ */ import { Context, Service } from 'cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' +import { Session, type SessionId } from '@deepseek-ai/dsh-session' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title' import type { @@ -19,6 +19,7 @@ import type { SessionEventTraceRequest, SessionEventWindow, SessionLineageTrace, + SessionLogSnapshot, SessionRecord, SessionResultFilter, SessionSearchExecContext, @@ -118,6 +119,21 @@ export abstract class SessionQueryService extends Service { return this._corpus.listSessions() } + /** + * Read and replay-validate one complete logical session log without making it live. + * @param sessionId - live or persisted session id to read. + * @returns cloned header and complete raw event log from one observation. + * @throws when persistence, header compatibility, or replay validation fails. + */ + async readSession(sessionId: SessionId): Promise { + const loaded = await this._corpus.load(sessionId) + new Session(sessionId, loaded.events, loaded.header) + return { + session: structuredClone(loaded.header), + events: loaded.events.map(event => structuredClone(event)), + } + } + /** * Filter the complete logical corpus with provider-independent predicates. * @param filters - ANDed session metadata and availability clauses. diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index b231bd9f78..0f89de8156 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -39,6 +39,14 @@ export interface SessionSurfaceSnapshot { events: SurfaceEvent[] } +/** One validated detached observation of a logical session's complete raw log. */ +export interface SessionLogSnapshot { + /** Cloned session header selected from the same observation as `events`. */ + session: SessionHeader + /** Cloned contiguous raw events after persistence repair and replay validation. */ + events: SessionEvent[] +} + /** Lightweight metadata for one event within a logical session. */ export interface SessionEventRecord { /** Session that owns the event. */ diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index a2ea051dd0..a69c3bcb93 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -105,6 +105,25 @@ function rejectUnknown(reason: unknown): Promise { } describe('session-query exact reads', () => { + it('returns a detached replay-valid full log and rejects a corrupt persisted seed', async () => { + const valid = header('valid-log', 2) + const corrupt = header('corrupt-log', 1) + const validEvents = eventLog('valid') + const corruptEvents = [{ ...eventLog('bad')[0]!, seq: 1 }] + TestPersistence.reset([ + { meta: valid, events: validEvents }, + { meta: corrupt, events: corruptEvents }, + ]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + + const snapshot = await ctx.sessionQuery.readSession(valid.id) + expect(snapshot).toEqual({ session: valid, events: validEvents }) + Object.assign(snapshot.events[0]!, { time: 999 }) + expect(TestPersistence.entries.get(valid.id)?.events[0]?.time).toBe(10) + await expect(ctx.sessionQuery.readSession(corrupt.id)).rejects.toThrow('seed event at index 0 has seq 1') + }) + it('prefers a live owner that attaches while its persisted prefix is inspected', async () => { const shared = header('attach-during-inspect', 2) TestPersistence.reset([{ meta: shared, events: eventLog('persisted') }]) diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index abd8feec20..c37b2d5fb3 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -6,11 +6,12 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | | `parseResumeArg(argv)` | Split the `--resume ` / `--resume=` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh | +| `replaceResumeArg(argv, sessionId)` | Remove an existing resume flag and append one canonical `--resume ` pair while preserving positional arguments | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | -| `boot(binName, absoluteConfigPath, patches?)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL with the optional overlay patches, await the whole tree, assert entries loaded, return the root context | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount, then mount the Loader/include tree, await it, assert entries loaded, and return the root context | | `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | | `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index c303ac1e71..195f0bdb11 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -80,6 +80,18 @@ export function parseResumeArg( return { resumeSessionId, rest } } +/** + * Replace any existing resume flag with one canonical trailing `--resume ` pair. + * @param argv - current arguments after command dispatch. + * @param sessionId - selected session id. + * @returns flag-normalized arguments for a process replacement. + */ +export function replaceResumeArg(argv: readonly string[], sessionId: string): string[] { + if (sessionId.length === 0) throw new Error(`${RESUME_FLAG} requires a non-empty session id`) + const { rest } = parseResumeArg(argv) + return [...rest, RESUME_FLAG, sessionId] +} + /** * Load the optional gitignored `.env` from `dir`. Missing files fall back to the * ambient environment; other read failures are reported through `warn`. @@ -216,12 +228,17 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { * (see {@link resolveConfigPath}). * @param patches - optional overlay patches applied over the included tree * (see {@link loadPersonalPatches}); an empty list mounts none. + * @param prepare - optional host setup run against the root context before any Loader entry mounts. * @returns the root context once every entry has started. */ export async function boot( - binName: string, absoluteConfigPath: string, patches?: PatchOptions[], + binName: string, + absoluteConfigPath: string, + patches?: PatchOptions[], + prepare?: (ctx: Context) => Promise | void, ): Promise { const ctx = new Context() + await prepare?.(ctx) ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 76e5238db7..6ea9c4e66e 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, - installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess, + installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -55,6 +55,15 @@ describe('parseResumeArg', () => { }) }) +describe('replaceResumeArg', () => { + it('keeps positional arguments and replaces either existing flag form', () => { + expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next']) + expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next']) + expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next']) + expect(() => replaceResumeArg([], '')).toThrow('non-empty session id') + }) +}) + describe('loadEnv', () => { it('loads variables from .env in the given dir', () => { const dir = tmp() @@ -196,6 +205,19 @@ describe('boot', () => { } }) + it('runs host preparation before the Loader tree mounts', async () => { + const dir = tmp() + writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n') + const prepared: Context[] = [] + const ctx = await boot(NAME, join(dir, 'cordis.yml'), undefined, (hostCtx) => { prepared.push(hostCtx) }) + try { + expect(prepared).toEqual([ctx]) + } finally { + await ctx.fiber.dispose() + } + }) + it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => { const dir = tmp() writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n') diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 21601fa280..ee3e787c3f 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,9 @@ The footer sums the session's reported usage as `↑ `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. -When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiting prints the resume command for the current session (once it has been persisted, so an abandoned session yields no hint), and `/resume` lists this workspace's persisted sessions newest-first, each with its resume command and a marker on the current one. `{session}` in the template expands to the session id; the TUI only prints commands to copy and never resumes in place. +`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, flushes it, stops the terminal UI, and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. + +`resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text. ## Config @@ -42,17 +44,20 @@ When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiti | `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview | | `maxQuestionOptions` | `8` | Visible options in a question panel | | `maxModelOptions` | `8` | Visible models in the model selector | +| `maxResumeOptions` | `8` | Visible sessions in the resume selector | | `questionDialogWidth` | `200` | Question-panel width in columns, clamped to the terminal | | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | | `modelDialogWidth` | `72` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | +| `resumeDialogWidth` | `88` | Resume-selector width in columns | +| `resumeDialogMaxHeight` | `24` | Resume-selector maximum rows | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | | `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | | `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Product suffix for the terminal window title. | -| `resumeCommand` | — | Shell command template for the exit hint and `/resume`, with `{session}` expanded to the session id; unset disables both. Needs a `sessionPersistence` backend | +| `resumeCommand` | — | Shell command template for the exit hint and hosts without in-place handoff, with `{session}` expanded to the session id | ```yaml - id: terminal diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 4d668ba697..76e9c9c7aa 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -33,9 +33,11 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-retry": "^0.0.1", + "@deepseek-ai/dsh-goal": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-reference": "^0.0.1", "@deepseek-ai/dsh-session-persistence": "^0.0.1", + "@deepseek-ai/dsh-session-query": "^0.0.1", "@deepseek-ai/dsh-session-title": "^0.0.1", "@deepseek-ai/dsh-skill": "^0.0.1", "@deepseek-ai/dsh-system-prompt": "^0.0.1", @@ -48,6 +50,12 @@ "@deepseek-ai/dsh-session-persistence": { "optional": true }, + "@deepseek-ai/dsh-session-query": { + "optional": true + }, + "@deepseek-ai/dsh-goal": { + "optional": true + }, "@deepseek-ai/dsh-skill": { "optional": true } @@ -60,6 +68,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index dfe420f1f6..7317f3000b 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -66,12 +66,17 @@ import { type SessionHeader, type TodoItem, } from '@deepseek-ai/dsh-session' +import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' import { formatSessionReferenceMention, parseSessionReferenceText, type SessionReferenceService, } from '@deepseek-ai/dsh-session-reference' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' +import type { + SessionLogSnapshot, + SessionRecord, +} from '@deepseek-ai/dsh-session-query' // Side-effect type import: declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. import type {} from '@deepseek-ai/dsh-session-persistence' @@ -120,9 +125,22 @@ declare module 'cordis' { interface Context { /** Terminal-only interaction service, available only while a TUI is mounted. */ tui: TuiExtensionService + /** Optional process host that can replace this TUI with a resumed session. */ + tuiResumeHost: TuiResumeHost } } +/** Process-lifecycle owner used by the shipped CLI for an atomic resume handoff. */ +export interface TuiResumeHost { + /** + * Dispose the current app and replace it with a runtime for `sessionId`. + * Success does not return. A host may reject before it commits teardown; + * after commit it owns fatal reporting and process exit. + * @param sessionId - validated persisted session selected by the user. + */ + handoff(sessionId: SessionId): Promise +} + /** * Optional terminal-local interaction service provided by one mounted TUI. * @@ -162,7 +180,7 @@ export { } from './file-autocomplete.ts' export const name = 'ui-tui' -export const inject = ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] +export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'tools', 'llm', 'systemPrompt', 'tokenMeter'] /** Model guidance for path-only file references selected through the TUI. */ export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.' @@ -177,6 +195,8 @@ export interface TuiConfig { maxQuestionOptions?: number /** Maximum models visible at once in the model selector. */ maxModelOptions?: number + /** Maximum sessions visible at once in the resume selector. */ + maxResumeOptions?: number /** User-question panel width in terminal columns, clamped to the terminal. */ questionDialogWidth?: number /** User-question panel maximum height in terminal rows. */ @@ -185,6 +205,10 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number + /** Resume-selector width in terminal columns. */ + resumeDialogWidth?: number + /** Resume-selector maximum height in terminal rows. */ + resumeDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -210,10 +234,13 @@ const showReasoningSchema = z.boolean().default(true) const maxToolOutputLinesSchema = z.number().step(1).min(1).default(6) const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) const maxModelOptionsSchema = z.number().step(1).min(1).default(8) +const maxResumeOptionsSchema = z.number().step(1).min(1).default(8) const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const resumeDialogWidthSchema = z.number().step(1).min(36).default(88) +const resumeDialogMaxHeightSchema = z.number().step(1).min(8).default(24) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) @@ -228,10 +255,13 @@ const tuiConfigSchemaFields = { maxToolOutputLines: maxToolOutputLinesSchema, maxQuestionOptions: maxQuestionOptionsSchema, maxModelOptions: maxModelOptionsSchema, + maxResumeOptions: maxResumeOptionsSchema, questionDialogWidth: questionDialogWidthSchema, questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, + resumeDialogWidth: resumeDialogWidthSchema, + resumeDialogMaxHeight: resumeDialogMaxHeightSchema, fileSearchMaxResults: fileSearchMaxResultsSchema, fileSearchMaxEntries: fileSearchMaxEntriesSchema, fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, @@ -251,11 +281,10 @@ export interface Config extends TuiConfig { /** Exact shared agent/session identity driven by this terminal. Defaults to `main`. */ sessionId?: string /** - * Shell command template shown for resuming this session: printed on exit and - * listed by `/resume`, with every `{session}` occurrence replaced by the live - * session id. Absent disables both surfaces. Deployments set it only when a - * persistence backend makes the session resumable (e.g. - * `RESUME_SESSION_ID={session} dsh`). + * Shell command fallback printed on exit or after selecting a session when + * the host cannot hand off in place. Every `{session}` becomes the selected + * id; the TUI never executes this text. Absent disables only the fallback, + * not the interactive selector. */ resumeCommand?: string } @@ -268,10 +297,13 @@ export const Config: z = z.object({ maxToolOutputLines: tuiConfigSchemaFields.maxToolOutputLines, maxQuestionOptions: tuiConfigSchemaFields.maxQuestionOptions, maxModelOptions: tuiConfigSchemaFields.maxModelOptions, + maxResumeOptions: tuiConfigSchemaFields.maxResumeOptions, questionDialogWidth: tuiConfigSchemaFields.questionDialogWidth, questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, + resumeDialogWidth: tuiConfigSchemaFields.resumeDialogWidth, + resumeDialogMaxHeight: tuiConfigSchemaFields.resumeDialogMaxHeight, fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, @@ -287,10 +319,13 @@ export interface ResolvedTuiConfig { maxToolOutputLines: number maxQuestionOptions: number maxModelOptions: number + maxResumeOptions: number questionDialogWidth: number questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number + resumeDialogWidth: number + resumeDialogMaxHeight: number fileSearchMaxResults: number fileSearchMaxEntries: number fileSearchExcludedDirectories: string[] @@ -314,6 +349,8 @@ export interface TuiRuntime { formatCwd?: (cwd: string | undefined) => string /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ now?(): number + /** Host-owned safe process handoff; absent leaves `resumeCommand` as the fallback. */ + handoffResume?: TuiResumeHost['handoff'] } /** @@ -328,10 +365,13 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf maxToolOutputLines: config?.maxToolOutputLines ?? 6, maxQuestionOptions: config?.maxQuestionOptions ?? 8, maxModelOptions: config?.maxModelOptions ?? 8, + maxResumeOptions: config?.maxResumeOptions ?? 8, questionDialogWidth: config?.questionDialogWidth ?? 200, questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 72, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, + resumeDialogWidth: config?.resumeDialogWidth ?? 88, + resumeDialogMaxHeight: config?.resumeDialogMaxHeight ?? 24, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], @@ -1236,6 +1276,173 @@ class ModelDialog implements Component { } } +interface ResumeRoute { + provider: string + model: string +} + +interface ResumeCandidate { + record: SessionRecord + occupied: boolean + title: string + lastActivityAt: number + lastTurn: string + route?: ResumeRoute + goalPhase?: GoalPhase + disabledReason?: string +} + +function resumeTurnLabel(snapshot: SessionLogSnapshot): string { + const event = snapshot.events.findLast(item => item.type === 'turn/end') + if (event === undefined) return 'no completed turn' + const reason = event.data.reason + switch (reason.kind) { + case 'completed': return `turn ${event.data.turn}: completed` + case 'aborted': return `turn ${event.data.turn}: cancelled` + case 'error': return `turn ${event.data.turn}: error` + case 'disposed': return `turn ${event.data.turn}: disposed` + case 'max-tokens': return `turn ${event.data.turn}: max tokens` + case 'rejected': return `turn ${event.data.turn}: rejected` + case 'interrupted': return `turn ${event.data.turn}: interrupted` + default: return `turn ${event.data.turn}: unknown result` + } +} + +function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { + const header = snapshot.events.findLast(item => item.type === 'request/header') + if (header?.type === 'request/header') { + return { provider: header.data.header.config.provider, model: header.data.header.config.model } + } + const assistant = snapshot.events.findLast(item => item.type === 'assistant/message') + return assistant?.type === 'assistant/message' + ? { provider: assistant.data.provenance.provider, model: assistant.data.provenance.model } + : undefined +} + +function summarizeResumeCandidate( + record: SessionRecord, + snapshot: SessionLogSnapshot, + currentId: SessionId, + cwd: string | undefined, + occupied: boolean, + availableProviders: ReadonlySet, +): ResumeCandidate { + const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' + const route = resumeRoute(snapshot) + const foldedGoal = foldGoal(snapshot.events).goal + let disabledReason: string | undefined + if (record.header.id === currentId) disabledReason = 'current session' + else if (record.live || occupied) disabledReason = 'occupied by another live agent' + else if (record.header.cwd !== cwd) disabledReason = 'different workspace' + else if (route !== undefined && !availableProviders.has(route.provider)) { + disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` + } + return { + record, + occupied, + title, + lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, + lastTurn: resumeTurnLabel(snapshot), + ...route === undefined ? {} : { route }, + ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, + ...disabledReason === undefined ? {} : { disabledReason }, + } +} + +/** Searchable keyboard selector over detached, preflighted resume summaries. */ +class ResumeDialog implements Component, Focusable { + private query = '' + private selectedIndex = 0 + private error = '' + focused = false + + constructor( + private readonly candidates: readonly ResumeCandidate[], + private readonly maxVisible: number, + private readonly palette: Palette, + private readonly done: (candidate: ResumeCandidate) => void, + private readonly cancel: () => void, + ) {} + + invalidate(): void {} + + private filtered(): ResumeCandidate[] { + const query = this.query.trim().toLocaleLowerCase() + if (query === '') return [...this.candidates] + return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) + || candidate.record.header.id.toLocaleLowerCase().includes(query)) + } + + handleInput(data: string): void { + this.invalidate() + const filtered = this.filtered() + if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { + this.cancel() + return + } + if (matchesKey(data, Key.up)) { + this.selectedIndex = filtered.length === 0 + ? 0 + : (this.selectedIndex + filtered.length - 1) % filtered.length + } else if (matchesKey(data, Key.down)) { + this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length + } else if (matchesKey(data, Key.enter)) { + const selected = filtered[this.selectedIndex] + if (selected === undefined) this.error = 'No session matches this search.' + else if (selected.disabledReason !== undefined) this.error = selected.disabledReason + else this.done(selected) + } else if (data === '\x7f' || data === '\b') { + this.query = Array.from(this.query).slice(0, -1).join('') + this.selectedIndex = 0 + this.error = '' + } else if (!Array.from(data).some(character => character < ' ' || character === '\x7f')) { + this.query += data + this.selectedIndex = 0 + this.error = '' + } + } + + render(width: number): string[] { + const innerWidth = Math.max(1, width - 4) + const filtered = this.filtered() + if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(this.maxVisible / 2), + filtered.length - this.maxVisible, + )) + const end = Math.min(filtered.length, start + this.maxVisible) + const body: string[] = [ + this.query === '' + ? `${this.palette.muted('Search:')} ${this.palette.dim('title or session id')}` + : this.palette.text(`Search: ${displayText(this.query)}`), + '', + ] + for (let index = start; index < end; index += 1) { + const candidate = filtered[index] as ResumeCandidate + const selected = index === this.selectedIndex + const status = [ + candidate.disabledReason === 'current session' ? 'current' : undefined, + candidate.record.live || candidate.occupied ? 'live' : undefined, + candidate.record.persisted ? 'persisted' : undefined, + ].filter((value): value is string => value !== undefined).join(' · ') + const lead = `${selected ? '›' : ' '} ${displayText(candidate.title)}` + body.push(selected ? this.palette.bold(this.palette.accent(lead)) : lead) + const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` + const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` + body.push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) + body.push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + if (candidate.disabledReason !== undefined) { + body.push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) + } + } + if (filtered.length === 0) body.push(this.palette.warning('No matching sessions.')) + if (filtered.length > this.maxVisible) body.push(this.palette.dim(`${this.selectedIndex + 1}/${filtered.length}`)) + body.push('', this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc cancel')) + if (this.error !== '') body.push(this.palette.error(displayText(this.error))) + return renderDialog('Resume session', body.flatMap(line => wrapTextWithAnsi(line, innerWidth)), width, this.palette) + } +} + class QuestionDialog implements Component, Focusable { private selectedIndex = 0 private selected = new Set() @@ -1585,6 +1792,7 @@ export function createTuiChat( const agent = ctx.agents.get(sessionId) if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`) const persistence = ctx.get('sessionPersistence') + const sessionQuery = ctx.get('sessionQuery') const resolved = resolveTuiConfig(config) const palette = createPalette(resolved.color) const mdTheme = markdownTheme(palette) @@ -1631,6 +1839,9 @@ export function createTuiChat( const referenceControllers = new Set() let activeQuestion: PendingQuestion | undefined let modelOverlay: TuiOverlaySession | undefined + let resumeOverlay: TuiOverlaySession | undefined + let resumeInFlight = false + let resumeScan = 0 let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } let contextWindow: number | undefined @@ -1640,6 +1851,8 @@ export function createTuiChat( > | undefined let modelCommands = Promise.resolve() const now = (): number => runtime.now?.() ?? Date.now() + const agentStatus = (): AgentStatus => agent.status + const isDisposed = (): boolean => disposed // A configured subtitle renders as a banner line; when absent, the banner has // no subtitle. The banner itself sweeps in on start (see startBannerReveal). @@ -2204,7 +2417,6 @@ export function createTuiChat( } return all .filter(header => header.cwd === agent.session.header.cwd) - .sort((a, b) => b.createdAt - a.createdAt) } /** @@ -2597,37 +2809,153 @@ export function createTuiChat( }) } - /** - * List this workspace's resumable sessions, newest first, each with its - * resume command and a marker on the current one. Warns when resume is not - * configured or no persistence backend is mounted; notes when nothing is - * persisted yet. The listing is asynchronous (a persistence scan), so the - * transcript updates once it resolves. - */ - const showResume = (): void => { - const template = config.resumeCommand - if (template === undefined) { - appendNotice('Resume is not configured for this app.', 'warning') - return + /** Build one display candidate without letting a corrupt neighbor abort the selector. */ + const readResumeCandidate = async ( + record: SessionRecord, + providers: ReadonlySet, + ): Promise => { + try { + const occupied = record.live || (record.persisted && persistence !== undefined + ? await persistence.isLive(record.header.id) + : false) + let snapshot: SessionLogSnapshot + const live = ctx.sessions.get(record.header.id) + if (live !== undefined) { + snapshot = { + session: structuredClone(live.header), + events: live.events.map(event => structuredClone(event)), + } + } else { + /* v8 ignore next -- caller checks the optional service before mapping records */ + if (sessionQuery === undefined) throw new Error('session query is unavailable') + snapshot = await sessionQuery.readSession(record.header.id) + } + return summarizeResumeCandidate( + record, + snapshot, + agent.session.id, + agent.session.header.cwd, + occupied, + providers, + ) + } catch (error: unknown) { + return { + record, + occupied: record.live, + title: 'Unreadable session', + lastActivityAt: record.header.createdAt, + lastTurn: 'log unavailable', + disabledReason: `session cannot be loaded: ${errorChain(error)}`, + } } - if (persistence === undefined) { - appendNotice('Resume is not available: no persistence backend is mounted.', 'warning') - return - } - void listWorkspaceSessions().then((sessions) => { - if (sessions.length === 0) { - appendNotice('No resumable sessions found for this workspace yet.', 'info') + } + + /** Re-read every mutable precondition immediately before terminal handoff. */ + const preflightResume = async (sessionId: SessionId): Promise => { + /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ + if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') + const initialStatus = agentStatus() + if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) + const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId) + if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) + const candidate = await readResumeCandidate( + record, + new Set(ctx.llm.listProviders().map(provider => provider.id)), + ) + if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) + const finalStatus = agentStatus() + if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) + return candidate + } + + const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { + if (resumeInFlight) return + resumeInFlight = true + try { + const checked = await preflightResume(candidate.record.header.id) + const hostHandoff = runtime.handoffResume + if (hostHandoff === undefined) { + const template = config.resumeCommand + const fallback = template?.replaceAll('{session}', checked.record.header.id) + await overlay.close() + resumeOverlay = undefined + appendNotice(fallback === undefined + ? 'Session is resumable, but this host cannot hand it off in place.' + : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') return } - chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.bold(palette.accent('Resumable sessions')), 1, 0)) - const lines = sessions.map((header) => { - const when = new Date(header.createdAt).toISOString().slice(0, 16).replace('T', ' ') - const marker = header.id === agent.session.id ? palette.success(' (current)') : '' - return `${palette.muted(when)}${marker}\n ${displayText(template.replaceAll('{session}', header.id))}` + await ctx.sessions.flush(agent.session) + if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) + await overlay.close() + resumeOverlay = undefined + await runtime.terminal.drainInput(100, 20) + ui.stop() + try { + await hostHandoff(checked.record.header.id) + throw new Error('resume host returned without replacing the process') + } catch (error: unknown) { + /* v8 ignore next -- a committed host disposes this TUI and never returns; pre-commit rejection keeps it live */ + if (!disposed) { + ui.start() + ui.setFocus(editor) + appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') + } + } + } catch (error: unknown) { + /* v8 ignore next -- disposal settles the overlay and suppresses late preflight diagnostics */ + if (!disposed) { + await overlay.close() + resumeOverlay = undefined + appendNotice(`Resume failed: ${errorChain(error)}`, 'error') + } + } finally { + resumeInFlight = false + } + } + + /** Open the current-workspace searchable session selector. */ + const showResume = (): void => { + if (agent.status !== 'idle') { + appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') + return + } + if (sessionQuery === undefined) { + appendNotice('Resume is not available: session query is not mounted.', 'warning') + return + } + const scan = ++resumeScan + void resumeOverlay?.close() + void sessionQuery.listSessions().then(async (records) => { + if (isDisposed() || scan !== resumeScan) return + const workspace = records.filter(record => record.header.cwd === agent.session.header.cwd) + const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) + const candidates = await Promise.all(workspace.map(record => readResumeCandidate(record, providers))) + candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt + || a.record.header.id.localeCompare(b.record.header.id)) + if (isDisposed() || scan !== resumeScan) return + const session = overlayManager.open({ + create: () => new ResumeDialog( + candidates, + resolved.maxResumeOptions, + palette, + (candidate) => { void handoffResume(candidate, session) }, + () => { void session.close() }, + ), + options: { + width: resolved.resumeDialogWidth, + maxHeight: resolved.resumeDialogMaxHeight, + anchor: 'center', + margin: 1, + }, + }) + resumeOverlay = session + void session.closed.then(() => { + /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ + if (resumeOverlay === session) resumeOverlay = undefined }) - chat.addChild(new Text(lines.join('\n'), 1, 0)) requestRender() + }, (error: unknown) => { + if (!disposed && scan === resumeScan) appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') }) } @@ -2828,6 +3156,14 @@ export function createTuiChat( } rebuildTranscript(true) + const restoredGoal = foldGoal(agent.session.events).goal + if (restoredGoal !== undefined && restoredGoal.phase !== 'complete') { + appendNotice( + `Goal restored (${restoredGoal.phase}) with automatic continuation disarmed. ` + + 'Human confirmation is required; send “继续” or run /goal resume.', + 'warning', + ) + } setStatus(agent.status) try { ui.start() @@ -2915,9 +3251,11 @@ export function apply(ctx: Context, config: Config): void { // Truecolor is a terminal capability, so detect it here at the process // boundary from COLORTERM; an explicit `truecolor` config value still wins. const truecolor = config.truecolor ?? ['truecolor', '24bit'].includes(process.env.COLORTERM ?? '') + const resumeHost = ctx.get('tuiResumeHost') mountTui(ctx, Object.assign({}, config, { truecolor }), { terminal: new ProcessTerminal(), exit: code => process.exit(code), + ...resumeHost === undefined ? {} : { handoffResume: sessionId => resumeHost.handoff(sessionId) }, }) } /* v8 ignore stop */ diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index c6da283236..6b9bd143d4 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -14,6 +14,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createTuiChat, type Config, type TuiRuntime } from '../src/index.ts' +import { TestSessionQueryService } from './session-query.ts' interface FakeAgent extends Agent { status: AgentStatus @@ -48,7 +49,14 @@ export interface TuiHarnessOptions { resolveModelContext?: (provider: string, model: string) => Promise } /** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */ - sessionPersistence?: { list(): Promise } + sessionPersistence?: { + list(): Promise + load?(id: ReturnType): Promise<{ meta: SessionHeader; events: Session['events'] }> + isLive?(id: ReturnType): Promise + } + handoffResume?: TuiRuntime['handoffResume'] + /** Set false to exercise the optional session-query degradation path. */ + mountSessionQuery?: boolean } export interface TuiHarness void> { @@ -118,7 +126,26 @@ export async function createTuiTestHarness undefined, + create: () => Promise.resolve(), + append: () => Promise.resolve(), + load: persistence.load === undefined + ? (id: ReturnType) => Promise.reject(new Error(`session "${id}" not found`)) + : (id: ReturnType) => persistence.load!(id), + inspect: persistence.load === undefined + ? (id: ReturnType) => Promise.reject(new Error(`session "${id}" not found`)) + : (id: ReturnType) => persistence.load!(id), + claimLive: () => Promise.resolve({ release: () => Promise.resolve() }), + isLive: persistence.isLive === undefined + ? () => Promise.resolve(false) + : (id: ReturnType) => persistence.isLive!(id), + } as never) + } + if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) { + await ctx.plugin(TestSessionQueryService) } const sessionId = SessionId('main-session') const session = ctx.sessions.create( @@ -178,6 +205,7 @@ export async function createTuiTestHarness { expect(unwrapped.name).toBe('ui-tui') expect(unwrapped.inject).toEqual([ 'agents', + 'sessions', 'commands', 'userInteraction', 'tools', diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt index 7711b71636..d78aa6d31f 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -1,7 +1,7 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=10 bufferRow=10 +cursor hidden column=0 viewportRow=31 bufferRow=31 buffer 0| " DEEPSEEK HARNESS" style 1-8 fg=bright-blue bold @@ -10,23 +10,60 @@ buffer style 1-21 fg=bright-black 2| " deepseek-v4-flash • main-session" style 1-34 dim -3| -4| " Resumable sessions " - style 1-18 fg=bright-blue bold -5| " 2024-01-02 03:04 (current) " - style 1-16 fg=bright-black - style 17-26 fg=green -6| " RESUME_SESSION_ID=main-session dsh " -7| " 2024-01-01 00:00 " - style 1-16 fg=bright-black -8| " RESUME_SESSION_ID=earlier-session dsh " -9| "────────────────────────────────────────────────────────────────────────────────────────────" +3| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -10| " " +4| " " style 1-1 inverse -11| "────────────────────────────────────────────────────────────────────────────────────────────" +5| "────────────────────────────────────────────────────────────────────────────────────────────" style 0-91 dim -12| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" +6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" style 0-43 dim style 65-91 dim -13-31| +7-8| +9| " ╭ Resume session ──────────────────────────────────────────────────────────────────────╮ " + style 2-89 fg=bright-blue +10| " │ Search: title or session id │ " + style 2-2 fg=bright-blue + style 4-10 fg=bright-black + style 12-30 dim + style 89-89 fg=bright-blue +11| " │ │ " + style 2-2 fg=bright-blue + style 89-89 fg=bright-blue +12| " │ › Untitled session │ " + style 2-2 fg=bright-blue + style 4-21 fg=bright-blue bold + style 89-89 fg=bright-blue +13| " │ 2026-07-23T08:00:00.000Z · no completed turn · route unavailable │ " + style 2-2 fg=bright-blue + style 4-69 fg=bright-black + style 89-89 fg=bright-blue +14| " │ current · live · main-session │ " + style 2-2 fg=bright-blue + style 4-34 dim + style 89-89 fg=bright-blue +15| " │ unavailable: current session │ " + style 2-2 fg=bright-blue + style 4-33 fg=yellow + style 89-89 fg=bright-blue +16| " │ Resume selector design │ " + style 2-2 fg=bright-blue + style 89-89 fg=bright-blue +17| " │ 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro │ " + style 2-2 fg=bright-blue + style 4-76 fg=bright-black + style 89-89 fg=bright-blue +18| " │ persisted · earlier-session │ " + style 2-2 fg=bright-blue + style 4-32 dim + style 89-89 fg=bright-blue +19| " │ │ " + style 2-2 fg=bright-blue + style 89-89 fg=bright-blue +20| " │ Type to search • ↑/↓ navigate • Enter resume • Esc cancel │ " + style 2-2 fg=bright-blue + style 4-60 dim + style 89-89 fg=bright-blue +21| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ " + style 2-89 fg=bright-blue +22-31| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 182f9aae14..16d6794002 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -646,13 +646,27 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) - it('lists this workspace\'s resumable sessions with their commands', async () => { + it('opens the searchable resume selector with log-backed session summaries', async () => { + const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-23T08:00:00.000Z')) + const earlier = { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' } const harness = await setupSnapshot({ config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' }, - sessionPersistence: { list: async () => [ - { version: 0, id: SessionId('main-session'), createdAt: Date.parse('2024-01-02T03:04:00Z'), cwd: '/workspace/project' }, - { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' }, - ] }, + sessionPersistence: { + list: async () => [earlier], + load: async () => ({ + meta: earlier, + events: [ + { type: 'turn/start', seq: 0, time: Date.parse('2024-01-01T00:00:01Z'), data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: Date.parse('2024-01-01T00:00:02Z'), data: { content: [{ type: 'text', text: 'restore the selector' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: Date.parse('2024-01-01T00:00:03Z'), data: { turn: 1, step: 1 } }, + { type: 'request/header', seq: 3, time: Date.parse('2024-01-01T00:00:04Z'), data: { header: { config: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, reason: 'initial' } }, + { type: 'assistant/message', seq: 4, time: Date.parse('2024-01-01T00:00:05Z'), data: { turn: 1, step: 1, content: [{ type: 'text', text: 'ready' }], provenance: { provider: 'deepseek', model: 'deepseek-v4-pro' } }, surfaceOp: 'append' }, + { type: 'step/end', seq: 5, time: Date.parse('2024-01-01T00:00:06Z'), data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 6, time: Date.parse('2024-01-01T00:00:07Z'), data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'session/title', seq: 7, time: Date.parse('2024-01-01T00:00:08Z'), data: { title: 'Resume selector design', messageSeqs: [1], source: { kind: 'fallback' } } }, + ], + }), + }, }, { columns: 92, rows: 32 }) harness.terminal.send('/resume') harness.terminal.send('\r') @@ -662,6 +676,7 @@ describe('TUI terminal-state snapshots', () => { await harness.terminal.flush() await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) + dateNow.mockRestore() }) it('pins the detailed session diagnostics card', async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 724ef9697c..a6f8d30341 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -6,8 +6,10 @@ import { Context } from 'cordis' import { CombinedAutocompleteProvider, type Terminal } from '@earendil-works/pi-tui' import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent' import { type LlmCallConfig } from '@deepseek-ai/dsh-llm' +import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal' import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands' -import SessionStore, { SessionId, type JsonValue, type SessionHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SessionRecord } from '@deepseek-ai/dsh-session-query' import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-session-title' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' @@ -152,10 +154,13 @@ describe('TUI config', () => { maxToolOutputLines: 6, maxQuestionOptions: 8, maxModelOptions: 8, + maxResumeOptions: 8, questionDialogWidth: 200, questionDialogMaxHeight: 20, modelDialogWidth: 72, modelDialogMaxHeight: 20, + resumeDialogWidth: 88, + resumeDialogMaxHeight: 24, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], @@ -169,10 +174,13 @@ describe('TUI config', () => { maxToolOutputLines: 2, maxQuestionOptions: 3, maxModelOptions: 4, + maxResumeOptions: 5, questionDialogWidth: 60, questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + resumeDialogWidth: 84, + resumeDialogMaxHeight: 22, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -185,10 +193,13 @@ describe('TUI config', () => { maxToolOutputLines: 2, maxQuestionOptions: 3, maxModelOptions: 4, + maxResumeOptions: 5, questionDialogWidth: 60, questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, + resumeDialogWidth: 84, + resumeDialogMaxHeight: 22, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -204,6 +215,21 @@ describe('resume command and /resume', () => { const RESUME = 'RESUME_SESSION_ID={session} dsh' const header = (id: string, createdAt: number, cwd: string): SessionHeader => ({ version: 0, id: SessionId(id), createdAt, cwd }) + const resumeEvents = ( + title: string, + provider = 'deepseek', + time = 100, + reason: TurnEndReason = { kind: 'completed' }, + ): SessionEvent[] => [ + { type: 'turn/start', seq: 0, time, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'user/message', seq: 1, time: time + 1, data: { content: [{ type: 'text', text: 'resume me' }], source: { kind: 'user' } }, surfaceOp: 'append' }, + { type: 'step/start', seq: 2, time: time + 2, data: { turn: 1, step: 1 } }, + { type: 'request/header', seq: 3, time: time + 3, data: { header: { config: { provider, model: 'model-1' } }, reason: 'initial' } }, + { type: 'assistant/message', seq: 4, time: time + 4, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'done' }], provenance: { provider, model: 'model-1' } }, surfaceOp: 'append' }, + { type: 'step/end', seq: 5, time: time + 5, data: { turn: 1, step: 1 } }, + { type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } }, + { type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } }, + ] it('prints the resume command on exit once the session is persisted', async () => { const result = await setup({ @@ -243,73 +269,589 @@ describe('resume command and /resume', () => { await dispose(result) }) - it('lists this workspace\'s sessions newest-first and marks the current one', async () => { + it('opens a newest-active-first searchable selector and Esc cancels without side effects', async () => { + const older = header('older-session', 500, '/workspace') + const newer = header('newer-session', 2000, '/workspace') + const handoff = vi.fn>() const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME }, + handoffResume: handoff, sessionPersistence: { - list: async () => [ - header('main-session', 1000, '/workspace'), - header('older-session', 500, '/workspace'), - header('newer-session', 2000, '/workspace'), - header('foreign-session', 3000, '/elsewhere'), - ], + list: async () => [older, newer, header('foreign-session', 3000, '/elsewhere')], + load: async id => id === newer.id + ? { meta: newer, events: resumeEvents('Newer product work', 'deepseek', 300) } + : { meta: older, events: resumeEvents('Older investigation', 'deepseek', 100) }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + const output = result.terminal.output + expect(output).toContain('Resume session') + expect(output).toContain('Newer product work') + expect(output).toContain('Older investigation') + expect(output).toContain('current · live') + expect(output.indexOf('Newer product work')).toBeLessThan(output.indexOf('Older investigation')) + expect(output).not.toContain('foreign-session') + result.terminal.send('Older') + await tick() + expect(result.terminal.output).toContain('Search: Older') + result.terminal.send('\x1b') + await tick() + expect(handoff).not.toHaveBeenCalled() + await dispose(result) + }) + + it('handles selector navigation, empty matches, and backspace search edits', async () => { + const target = header('keyboard-target', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Keyboard target') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[A') + result.terminal.send('\t') + result.terminal.send('zz') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('No session matches this search') + result.terminal.send('\x7f') + result.terminal.send('\x7f') + await tick() + expect(result.terminal.output).toContain('Search: title or session id') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('current session') + result.terminal.send('\x1b') + await dispose(result) + }) + + it('clips candidate count through the configured visible-session limit', async () => { + const targets = [header('limited-a', 10, '/workspace'), header('limited-b', 20, '/workspace')] + const result = await setup({ + cwd: '/workspace', + config: { maxResumeOptions: 1 }, + sessionPersistence: { + list: async () => targets, + load: async id => ({ + meta: targets.find(target => target.id === id)!, + events: resumeEvents(`Limited ${id}`), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('1/3') + await dispose(result) + }) + + it.each([ + [{ kind: 'aborted' }, 'cancelled'], + [{ kind: 'error', step: 1, message: 'failed' }, 'error'], + [{ kind: 'disposed' }, 'disposed'], + [{ kind: 'max-tokens' }, 'max tokens'], + [{ kind: 'rejected', reason: 'policy' }, 'rejected'], + [{ kind: 'interrupted' }, 'interrupted'], + [{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'], + ] as const)('renders the last turn result %s', async (reason, label) => { + const target = header(`turn-${label}`, 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek', 100, reason) }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain(`turn 1: ${label}`) + await dispose(result) + }) + + it('refuses while running instead of cancelling or switching', async () => { + const result = await setup({ cwd: '/workspace', status: 'running' }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('finish or be cancelled first') + expect(result.agent.cancelled).toEqual([]) + await dispose(result) + }) + + it('warns when the optional session-query service is absent', async () => { + const result = await setup({ cwd: '/workspace', mountSessionQuery: false }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('session query is not mounted') + await dispose(result) + }) + + it('keeps persisted query records readable when live-lease inspection is unavailable', async () => { + const target = header('query-only-persisted', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ + header: target, + live: false, + persisted: true, + }]), + readSession: () => Promise.resolve({ + session: target, + events: resumeEvents('Query-only persisted session'), + }), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Query-only persisted session') + expect(result.terminal.output).toContain('persisted') + expect(result.terminal.output).not.toContain('session cannot be loaded') + await dispose(result) + }) + + it('contains a session-query scan failure in the current TUI', async () => { + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.reject(new Error('index unavailable')), + } as never) }, }) result.terminal.send('/resume') result.terminal.send('\r') await tick() - const output = result.terminal.output - expect(output).toContain('Resumable sessions') - expect(output).toContain('RESUME_SESSION_ID=main-session dsh') - expect(output).toContain('(current)') - expect(output).toContain('RESUME_SESSION_ID=newer-session dsh') - expect(output).not.toContain('foreign-session') - // Newest-first: the newer session's command precedes the current session's. - // Match the full resume command, not the bare id: the banner detail line - // echoes the current session id (`main-session`) above the listing. - expect(output.indexOf('RESUME_SESSION_ID=newer-session')).toBeLessThan( - output.indexOf('RESUME_SESSION_ID=main-session'), - ) - expect(output.indexOf('RESUME_SESSION_ID=main-session')).toBeLessThan( - output.indexOf('RESUME_SESSION_ID=older-session'), - ) + expect(result.terminal.output).toContain('Resume session scan failed: index unavailable') + expect(result.terminal.stopped).toBe(0) await dispose(result) }) - it('warns from /resume when resume is not configured', async () => { - const result = await setup({ cwd: '/workspace' }) - result.terminal.send('/resume') - result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Resume is not configured') - await dispose(result) - }) - - it('warns from /resume when no persistence backend is mounted', async () => { - const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } }) - result.terminal.send('/resume') - result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('no persistence backend is mounted') - await dispose(result) - }) - - it('notes from /resume when no workspace sessions are persisted yet', async () => { + it('supersedes a slower prior selector scan', async () => { + const first = Promise.withResolvers() + let calls = 0 const result = await setup({ - cwd: '/workspace', - config: { resumeCommand: RESUME }, - sessionPersistence: { list: async () => [header('foreign-session', 10, '/elsewhere')] }, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => ++calls === 1 ? first.promise : Promise.resolve([]), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + first.reject(new Error('superseded scan failed')) + await tick() + expect(calls).toBe(2) + expect(result.terminal.output).toContain('No matching sessions') + expect(result.terminal.output).not.toContain('superseded scan failed') + result.terminal.send('\x1b[A') + result.terminal.send('\x1b[B') + await dispose(result) + }) + + it('drops a selector scan that resolves after TUI disposal', async () => { + const listing = Promise.withResolvers() + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { listSessions: () => listing.promise } as never) + }, }) result.terminal.send('/resume') result.terminal.send('\r') await tick() - expect(result.terminal.output).toContain('No resumable sessions found') + await dispose(result) + listing.resolve([]) + await tick() + expect(result.terminal.stopped).toBeGreaterThan(0) + }) + + it('drops loaded selector summaries when the TUI disposed during log reads', async () => { + const target = header('dispose-during-load', 10, '/workspace') + const loading = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>() + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: () => loading.promise, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + await dispose(result) + loading.resolve({ meta: target, events: resumeEvents('Disposed load') }) + await tick() + expect(result.terminal.stopped).toBeGreaterThan(0) + }) + + it('preflights route availability and occupied or corrupt sessions without losing the current TUI', async () => { + const missing = header('missing-route', 10, '/workspace') + const occupied = header('occupied', 20, '/workspace') + const corrupt = header('corrupt', 30, '/workspace') + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { + list: async () => [missing, occupied, corrupt], + isLive: async id => id === occupied.id, + load: async (id) => { + if (id === corrupt.id) throw new Error('checksum mismatch') + return { + meta: id === missing.id ? missing : occupied, + events: resumeEvents(id === missing.id ? 'Missing adapter' : 'Busy session', id === missing.id ? 'absent-provider' : 'deepseek'), + } + }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Missing adapter') + expect(result.terminal.output).toContain('absent-provider/model-1') + expect(result.terminal.output).toContain('Busy session') + expect(result.terminal.output).toContain('Unreadable session') + result.terminal.send('Missing adapter') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('route is currently unavailable') + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + + it('falls back to assistant provenance and header creation time for sparse logs', async () => { + const assistantOnly = header('assistant-route', 20, '/workspace') + const empty = header('empty-log', 10, '/workspace') + const events = resumeEvents('Assistant route', 'deepseek') + .filter(event => event.type !== 'request/header') + .map((event, seq) => ({ ...event, seq })) as SessionEvent[] + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [assistantOnly, empty], + load: async id => id === assistantOnly.id + ? { meta: assistantOnly, events } + : { meta: empty, events: [] }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('deepseek/model-1') + expect(result.terminal.output).toContain(new Date(empty.createdAt).toISOString()) + await dispose(result) + }) + + it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => { + const target = header('target-session', 10, '/workspace') + const handoff = vi.fn>(() => Promise.reject(new Error('test host retained process'))) + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Target session') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Target session') + result.terminal.send('\r') + await tick(); await tick() + expect(handoff).toHaveBeenCalledTimes(1) + expect(handoff).toHaveBeenCalledWith(target.id) + expect(result.terminal.stopped).toBeGreaterThan(0) + expect(result.terminal.output).toContain('Resume handoff failed: test host retained process') + await dispose(result) + }) + + it('restores the UI when a host returns instead of replacing the process', async () => { + const target = header('returning-host', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + handoffResume: async () => undefined as never, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Returning host') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Returning host') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('resume host returned without replacing the process') + await dispose(result) + }) + + it('keeps the current TUI when the selected log fails its second preflight load', async () => { + const target = header('racing-corruption', 10, '/workspace') + let loads = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => [target], + load: async () => { + if (++loads > 1) throw new Error('log changed during selection') + return { meta: target, events: resumeEvents('Racing corruption') } + }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Racing corruption') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume failed: session cannot be loaded: failed to inspect session') + expect(result.terminal.output).toContain('log changed during selection') + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + + it('rejects a candidate whose cwd changes between listing and preflight', async () => { + const target = header('moving-workspace', 10, '/workspace') + let listings = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => [++listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere')], + load: async () => ({ + meta: listings <= 2 ? target : header('moving-workspace', 10, '/elsewhere'), + events: resumeEvents('Moving workspace'), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Moving workspace') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('different workspace') + await dispose(result) + }) + + it('admits only one handoff while the selected preflight is pending', async () => { + const target = header('single-handoff', 10, '/workspace') + const preflight = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>() + let loads = 0 + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: () => ++loads === 1 + ? Promise.resolve({ meta: target, events: resumeEvents('Single handoff') }) + : preflight.promise, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Single handoff') + result.terminal.send('\r') + result.terminal.send('\r') + await tick() + preflight.resolve({ meta: target, events: resumeEvents('Single handoff') }) + await tick(); await tick() + expect(loads).toBe(2) + await dispose(result) + }) + + it('rechecks running state and candidate existence before loading the selected log', async () => { + const target = header('preflight-races', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Preflight races') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.agent.status = 'running' + result.terminal.send('Preflight races') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)') + result.agent.status = 'idle' + await dispose(result) + + let disappearingLists = 0 + const disappearing = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => ++disappearingLists <= 2 ? [target] : [], + load: async () => ({ meta: target, events: resumeEvents('Disappearing target') }), + }, + }) + disappearing.terminal.send('/resume') + disappearing.terminal.send('\r') + await tick(); await tick() + disappearing.terminal.send('Disappearing target') + disappearing.terminal.send('\r') + await tick() + expect(disappearing.terminal.output).toContain('is no longer available') + await dispose(disappearing) + }) + + it('rechecks idleness after the selected log finishes loading', async () => { + const target = header('load-turns-running', 10, '/workspace') + let loads = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: vi.fn(), + sessionPersistence: { + list: async () => [target], + load: async () => { + loads += 1 + if (loads === 2) result.agent.status = 'running' + return { meta: target, events: resumeEvents('Load turns running') } + }, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Load turns running') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)') + result.agent.status = 'idle' + await dispose(result) + }) + + it('keeps resumeCommand as a displayed fallback when the host cannot hand off', async () => { + const target = header('fallback-session', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + config: { resumeCommand: RESUME }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Fallback target') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Fallback target') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:') + expect(result.terminal.output).toContain('RESUME_SESSION_ID=fallback-session') + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + + it('keeps the selector independent from an absent command fallback', async () => { + const target = header('no-fallback-session', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('No fallback target') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('No fallback target') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Session is resumable, but this host cannot hand it off in place') + await dispose(result) + }) + + it('rechecks idleness after the current-session flush', async () => { + const target = header('post-flush-running', 10, '/workspace') + const control: { setRunning?: () => void } = {} + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.on('session/flush', () => { control.setRunning?.() }) + }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Post-flush running') }), + }, + }) + control.setRunning = () => { result.agent.status = 'running' } + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Post-flush running') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume requires an idle agent (status: running)') + expect(handoff).not.toHaveBeenCalled() + result.agent.status = 'idle' await dispose(result) }) }) describe('pi-tui chat lifecycle and transcript', () => { + it('restores durable goal phase without implying automatic continuation', async () => { + const change: GoalSnapshotChangeMeta = { + kind: 'goal/change', + version: GOAL_CHANGE_VERSION, + operation: 'create', + goal: { + id: GoalId('restored-goal'), + revision: 1, + objective: 'Resume only with human confirmation', + phase: 'active', + maxGoalRounds: 4, + }, + roundsStarted: 0, + createdAt: 10, + updatedAt: 10, + } + const result = await setup({ + beforeMount(session) { + session.append('context/message', { + content: renderGoalChange(change), + source: { kind: 'goal', goalId: change.goal.id, revision: change.goal.revision, round: 0 }, + meta: change as unknown as JsonValue, + }, { surfaceOp: 'append' }) + }, + }) + expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed') + expect(result.terminal.output).toContain('/goal resume') + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('goal active') + await dispose(result) + }) + it('uses the latest log-backed title for the header subtitle and terminal window', async () => { const result = await setup({ // A fixed short cwd keeps the footer's token counters inside the 88-column diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json index cf0a2b544f..3560d6bc9d 100644 --- a/packages/ui/tui/tsconfig.json +++ b/packages/ui/tui/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/agent-loop" }, + { + "path": "../../goal/goal" + }, { "path": "../../core/session" }, @@ -29,6 +32,9 @@ { "path": "../../session-persistence/session-persistence" }, + { + "path": "../../session-query/session-query" + }, { "path": "../../session-title/session-title" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e0d5cd798..21eb750454 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,6 +149,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session + '@deepseek-ai/dsh-tui': + specifier: workspace:^ + version: link:../../packages/ui/tui + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) apps/web: dependencies: @@ -3836,6 +3842,9 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../commands + '@deepseek-ai/dsh-goal': + specifier: workspace:^ + version: link:../../goal/goal '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 8318d59996..6be2d7ed83 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -52,6 +52,7 @@ export const LINK_MAP: Record = { SessionEvent: 'core.md', SessionId: 'core.md', SessionStartSource: 'core.md', + SessionLogSnapshot: 'session-query.md', SessionSurfaceSnapshot: 'session-query.md', ApprovalOutcome: 'approval.md', ApprovalPolicy: 'approval.md', @@ -94,6 +95,7 @@ export const LINK_MAP: Record = { CreateSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', SessionLocation: 'persistence.md', + SessionLiveLease: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 89a0778827..1472499819 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -379,6 +379,11 @@ "symbol": "SessionLocation", "source": "packages/session-persistence/session-persistence/src/index.ts" }, + { + "doc": "docs/core-data-structures/persistence.md", + "symbol": "SessionLiveLease", + "source": "packages/session-persistence/session-persistence/src/lease.ts" + }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", @@ -389,6 +394,11 @@ "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionLogSnapshot", + "source": "packages/session-query/session-query/src/types.ts" + }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSurfaceSnapshot", From 54d986ed87a022643d1de9b890712635942882bc Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 24 Jul 2026 12:58:53 +0800 Subject: [PATCH 10/15] fix(tui): close resume handoff races --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 4 +- .../2026-07-21-tui-resume-command.zh.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/module-graph.md | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/src/index.ts | 10 +- .../tests/jsonl.spec.ts | 7 + .../session-persistence-sqlite/README.md | 1 + .../session-persistence-sqlite/src/index.ts | 6 +- .../tests/sqlite.spec.ts | 18 +- .../session-persistence/README.md | 1 + .../session-persistence/src/index.ts | 7 +- .../session-persistence/src/lease.ts | 93 +++++---- .../session-persistence/tests/lease.spec.ts | 30 +++ packages/ui/tui/README.md | 2 +- packages/ui/tui/package.json | 3 - packages/ui/tui/src/index.ts | 65 +++++-- packages/ui/tui/tests/harness.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 182 ++++++++++++++++++ 20 files changed, 375 insertions(+), 76 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index 42370c5dad..c04f198e4a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 23755696a9b7b379f0341c472769684839b37211 -2026-07-21-tui-resume-command.zh.md: cd2e19a2ef95409e8e11199f08afa996e8b07414 +2026-07-21-tui-resume-command.md: cd08b56f1e887473fd1df9f5f6055cb7c5e0a9b4 +2026-07-21-tui-resume-command.zh.md: ef641292dd178e19f94b6f79d3ab8607da27ee6d diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 23755696a9..cd08b56f1e 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -14,9 +14,9 @@ The original `/resume` printed shell commands. It did not let a keyboard user in `session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate repeats the load, cwd, occupancy, and route checks so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. Running agents are never switched or cancelled implicitly. -First-party persistence backends implement a cross-process live lease under the shared coordinator. JSONL uses an owner-only lock record; SQLite uses a `live_session_leases` row. Both retain PID plus an exec-stable nonce, reject another live process, reclaim a dead PID, and release only after the exact session lifecycle drains. `AgentLoop.resume()` claims before load, closing the preflight/start race. +First-party persistence backends implement a cross-process live lease under the shared coordinator. JSONL uses an owner-only lock record; SQLite uses a `live_session_leases` row. Both retain PID plus an exec-stable nonce, reject another live process, reclaim a dead PID or a same-PID different-incarnation owner, and release only after the exact session lifecycle drains. A final process-local release excludes reacquisition until the physical lease settles. `AgentLoop.resume()` claims before load, closing the preflight/start race. -After preflight, the TUI flushes the current session and stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than spawning a second terminal owner. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. +After preflight, the TUI claims the target's exec-stable live lease before flushing the current session. A lost claim race remains in the current TUI; any later recoverable failure releases the reservation. The TUI then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process while retaining the target reservation rather than spawning a second terminal owner. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. `resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index cd2e19a2ef..ef641292dd 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -14,9 +14,9 @@ Status: implemented `session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会再次检查日志加载、cwd、占用情况和路由,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。系统绝不会隐式切换或取消处于运行状态的 agent。 -第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 的租约,并且仅在对应会话生命周期完全停稳后释放租约。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。 +第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 或 PID 相同但进程代际不同的租约,并且仅在对应会话生命周期完全停稳后释放租约。进程内最后一个引用开始释放后,新的领取操作必须等待物理租约完成释放再重新获取。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。 -预检通过后,TUI 先刷写当前会话并停止终端,再调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 +预检通过后,TUI 会先领取目标会话在进程替换前后保持稳定的活跃租约,再刷写当前会话。如果目标在预检后被其他进程抢占,当前 TUI 会继续运行;之后任何可恢复失败也会释放该预留租约。随后 TUI 停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,同时保留目标预留租约,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 `resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 5b3f7c4afb..f0ee05d705 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -983,7 +983,7 @@ isLive(id: SessionId): Promise Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLiveLease](../core-data-structures/persistence.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:55`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:60`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) diff --git a/docs/module-graph.md b/docs/module-graph.md index 7e2ee76934..212fd723a7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -674,11 +674,13 @@ flowchart TD pkg_tui --> pkg_agent pkg_tui --> pkg_agent_loop pkg_tui --> pkg_commands + pkg_tui --> pkg_goal pkg_tui --> pkg_invariants pkg_tui --> pkg_llm pkg_tui --> pkg_llm_retry pkg_tui --> pkg_session pkg_tui --> pkg_session_persistence + pkg_tui --> pkg_session_query pkg_tui --> pkg_session_reference pkg_tui --> pkg_session_title pkg_tui --> pkg_skill @@ -898,7 +900,7 @@ flowchart TD | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`system-prompt`](../packages/core/system-prompt), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session-title/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 4aaf30f45a..86a8c0f1d6 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -69,6 +69,6 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr - **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). -- **Lease scope is local-host advisory ownership** — PID liveness prevents two ordinary local Harness processes from resuming the same id, but it is not a distributed lease for shared network filesystems or hostile principals. +- **Lease scope is local-host advisory ownership** — PID plus same-process nonce checks prevent two ordinary local Harness processes from resuming the same id, but foreign PID reuse remains fail-closed and this is not a distributed lease for shared network filesystems or hostile principals. - **A crash during stale-lease takeover fails closed** — if the reclaiming process itself crashes while holding the short-lived `.reclaim` guard, an operator must remove that guard after confirming no recovery is active. - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index a3ed611a4c..8d96a17681 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -14,7 +14,7 @@ import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - sessionLeaseProcessIsLive, shareSessionLiveLease, + sessionLeaseOwnerIsLive, shareSessionLiveLease, type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' @@ -314,7 +314,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error const current = await this.readLiveLease(path) if (current !== undefined && current.pid === owner.pid && current.nonce === owner.nonce) break - if (current === undefined || sessionLeaseProcessIsLive(current.pid)) { + if (current === undefined || sessionLeaseOwnerIsLive(current, owner)) { throw new Error(`session "${id}" is occupied by another live process`) } const reclaimPath = `${path}.reclaim` @@ -335,7 +335,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi if (latest === undefined) { if (await this.exists(path)) throw new Error(`session "${id}" has an unreadable live-process lease`) } else if (latest.pid !== owner.pid || latest.nonce !== owner.nonce) { - if (sessionLeaseProcessIsLive(latest.pid)) { + if (sessionLeaseOwnerIsLive(latest, owner)) { throw new Error(`session "${id}" is occupied by another live process`) } await rm(path, { force: true }) @@ -356,13 +356,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - /** Report one non-stale process lease and clean up a crashed owner's record. */ + /** Report one non-stale process lease; acquisition reclaims a crashed owner's record. */ async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise { const path = this.liveLeasePath(id) const current = await this.readLiveLease(path) if (current === undefined) return await this.exists(path) if (current.pid === owner.pid && current.nonce === owner.nonce) return true - if (sessionLeaseProcessIsLive(current.pid)) return true + if (sessionLeaseOwnerIsLive(current, owner)) return true return false } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 517cf7bb04..269d56f886 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -249,6 +249,13 @@ describe('SessionPersistenceJsonl: cross-process live leases', () => { const inheritedClaim = await ctx.sessionPersistence.claimLive(inherited) await inheritedClaim.release() + const reusedPid = SessionId('reused-pid') + const reusedPidPath = join(liveDir, `${encodeSegment(reusedPid)}.lock`) + await writeFile(reusedPidPath, JSON.stringify({ pid: process.pid, nonce: 'prior-incarnation' })) + await expect(ctx.sessionPersistence.isLive(reusedPid)).resolves.toBe(false) + const reusedPidClaim = await ctx.sessionPersistence.claimLive(reusedPid) + await reusedPidClaim.release() + await expect(ctx.sessionPersistence.claimLive(SessionId('x'.repeat(300)))) .rejects.toThrow() diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 374da93faa..42421b2e81 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -57,3 +57,4 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p - **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately. - **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve). - **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup). +- **Foreign PID reuse is fail-closed** — same-PID claimants compare the exec-stable nonce, while other processes conservatively retain a stale row until the reused PID exits or an operator verifies and removes it. diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 4399ee9c90..091880e87e 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -15,7 +15,7 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - sessionLeaseProcessIsLive, shareSessionLiveLease, + sessionLeaseOwnerIsLive, shareSessionLiveLease, type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' @@ -295,7 +295,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const current = this.liveLeaseFor(id) if (current !== undefined && (current.pid !== owner.pid || current.nonce !== owner.nonce)) { - if (sessionLeaseProcessIsLive(current.pid)) { + if (sessionLeaseOwnerIsLive(current, owner)) { throw new Error(`session "${id}" is occupied by another live process`) } this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id) @@ -323,7 +323,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const current = this.liveLeaseFor(id) if (current === undefined) return false if ((current.pid === owner.pid && current.nonce === owner.nonce) - || sessionLeaseProcessIsLive(current.pid)) return true + || sessionLeaseOwnerIsLive(current, owner)) return true this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?') .run(id, current.pid, current.nonce) return false diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a3aba22323..b670520a5c 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' @@ -13,7 +13,10 @@ import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../sessi import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' const dirs: string[] = [] -afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) +afterEach(async () => { + vi.restoreAllMocks() + for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) +}) async function expectFlushError(promise: Promise, message: RegExp): Promise { try { @@ -465,9 +468,16 @@ describe('SessionPersistenceSqlite: edge cases', () => { await b.ctx.sessionPersistence.list() const concrete = b.ctx.sessionPersistence as SessionPersistenceSqlite const owner = sessionLiveOwner() + const occupiedPid = process.pid + 1 + const originalKill = process.kill.bind(process) + vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + if (pid === occupiedPid) return true + return originalKill(pid, signal) + }) const db = openDatabase(path, 'wal') const insert = db.prepare('INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)') - insert.run('occupied-lease', process.pid, 'another-owner') + insert.run('occupied-lease', occupiedPid, 'another-owner') + insert.run('reused-pid', process.pid, 'prior-incarnation') insert.run('stale-claim', 2_147_483_647, 'dead-owner') insert.run('stale-inspect', 2_147_483_647, 'dead-owner') insert.run('owned-inspect', owner.pid, owner.nonce) @@ -475,11 +485,13 @@ describe('SessionPersistenceSqlite: edge cases', () => { await expect(concrete.acquireLive(SessionId('occupied-lease'), owner)) .rejects.toThrow('occupied by another live process') + const reused = await concrete.acquireLive(SessionId('reused-pid'), owner) const claim = await concrete.acquireLive(SessionId('stale-claim'), owner) expect(await concrete.inspectLive(SessionId('owned-inspect'), owner)).toBe(true) expect(await concrete.inspectLive(SessionId('stale-inspect'), owner)).toBe(false) expect(await concrete.inspectLive(SessionId('missing-inspect'), owner)).toBe(false) await claim() + await reused() await b.dispose() const memory = new Context() diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 2bfa3a31b1..0aec3a4bd5 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -85,3 +85,4 @@ Persistence does not mutate live request prefixes. A resumed loop can reuse prov - **No deletion or retention surface** — pruning stored sessions is out-of-band backend maintenance. - **`list()` is unpaginated and unfiltered** — it returns every stored session's header; fine for local stores, unindexed at scale. - **Repair-time synthetic closers are the only crash story** — a backend must synthesize `tool/result`/`step/end`/`turn/end` closers on load; there is no partial-turn resume that continues an interrupted turn instead of closing it. +- **Foreign PID reuse is fail-closed** — a claimant with the reused PID detects its different nonce and reclaims safely, but another process cannot observe that foreign process's private nonce and treats the PID as live until it exits or an operator verifies and removes the stale lease. diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 3aa602c8c0..d6bbee0616 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -13,7 +13,12 @@ import type { SessionLiveLease } from './lease.ts' // Re-export the metadata vocabulary so consumers import it from the seam. export type { SessionHeader } from '@deepseek-ai/dsh-session' export { SessionPersistenceRevision } from './revision.ts' -export { sessionLeaseProcessIsLive, sessionLiveOwner, shareSessionLiveLease } from './lease.ts' +export { + sessionLeaseOwnerIsLive, + sessionLeaseProcessIsLive, + sessionLiveOwner, + shareSessionLiveLease, +} from './lease.ts' export type { SessionLiveLease, SessionLiveOwner } from './lease.ts' /** Lightweight immutable source identity returned without loading a full log. */ diff --git a/packages/session-persistence/session-persistence/src/lease.ts b/packages/session-persistence/session-persistence/src/lease.ts index 5cc51117ab..148d8e117f 100644 --- a/packages/session-persistence/session-persistence/src/lease.ts +++ b/packages/session-persistence/session-persistence/src/lease.ts @@ -8,7 +8,7 @@ const LIVE_OWNER_ENV = 'DSH_SESSION_LIVE_OWNER' export interface SessionLiveOwner { /** Operating-system process id; retained across an `execve` handoff. */ readonly pid: number - /** Per-process-start nonce that distinguishes PID reuse. */ + /** Exec-stable process-start nonce used when the observer has the same PID. */ readonly nonce: string } @@ -42,9 +42,26 @@ export function sessionLeaseProcessIsLive(pid: number): boolean { } } +/** + * Whether a recorded owner still names this process incarnation or another live PID. + * A same-PID nonce mismatch proves reuse and is stale; an unrelated live PID is + * fail-closed because its private nonce is not observable across processes. + * @param recorded - owner stored in the backend lease. + * @param observer - identity of the process inspecting or claiming the lease. + * @returns whether the recorded owner must still be treated as live. + */ +export function sessionLeaseOwnerIsLive( + recorded: SessionLiveOwner, + observer: SessionLiveOwner, +): boolean { + if (recorded.pid === observer.pid) return recorded.nonce === observer.nonce + return sessionLeaseProcessIsLive(recorded.pid) +} + interface SharedLeaseEntry { refs: number readonly acquired: Promise<() => Promise> + finalizing?: Promise } const sharedLeases = new Map() @@ -59,40 +76,48 @@ export async function shareSessionLiveLease( key: string, acquire: () => Promise<() => Promise>, ): Promise<() => Promise> { - let entry = sharedLeases.get(key) - if (entry === undefined) { - entry = { refs: 0, acquired: acquire() } - sharedLeases.set(key, entry) - void entry.acquired.catch(() => { - /* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */ - if (sharedLeases.get(key) === entry) sharedLeases.delete(key) - }) - } - entry.refs += 1 - try { - await entry.acquired - } catch (error) { - entry.refs -= 1 - throw error - } - let releaseTask: Promise | undefined - return () => { - if (releaseTask !== undefined) return releaseTask - const task = (async () => { + for (;;) { + let entry = sharedLeases.get(key) + if (entry?.finalizing !== undefined) { + await entry.finalizing + continue + } + if (entry === undefined) { + entry = { refs: 0, acquired: acquire() } + sharedLeases.set(key, entry) + void entry.acquired.catch(() => { + /* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */ + if (sharedLeases.get(key) === entry) sharedLeases.delete(key) + }) + } + entry.refs += 1 + try { + await entry.acquired + } catch (error) { entry.refs -= 1 - if (entry.refs > 0 || sharedLeases.get(key) !== entry) return - const release = await entry.acquired - await release() - /* v8 ignore next -- the entry remains installed until this exact final release succeeds */ - if (sharedLeases.get(key) === entry) sharedLeases.delete(key) - })() - const wrapped = task.catch((error: unknown) => { - entry.refs += 1 - /* v8 ignore next -- this closure is the sole writer of its releaseTask until settlement */ - if (releaseTask === wrapped) releaseTask = undefined throw error - }) - releaseTask = wrapped - return wrapped + } + let releaseTask: Promise | undefined + return () => { + if (releaseTask !== undefined) return releaseTask + const task = (async () => { + entry.refs -= 1 + if (entry.refs > 0 || sharedLeases.get(key) !== entry) return + const release = await entry.acquired + await release() + /* v8 ignore next -- claims wait for finalization before they can replace this exact entry */ + if (sharedLeases.get(key) === entry) sharedLeases.delete(key) + })() + const wrapped = task.catch((error: unknown) => { + entry.refs += 1 + /* v8 ignore next -- this closure is the sole writer of its release state until settlement */ + if (entry.finalizing === wrapped) delete entry.finalizing + releaseTask = undefined + throw error + }) + if (entry.refs === 0 && sharedLeases.get(key) === entry) entry.finalizing = wrapped + releaseTask = wrapped + return wrapped + } } } diff --git a/packages/session-persistence/session-persistence/tests/lease.spec.ts b/packages/session-persistence/session-persistence/tests/lease.spec.ts index e35bba9311..8c38aac874 100644 --- a/packages/session-persistence/session-persistence/tests/lease.spec.ts +++ b/packages/session-persistence/session-persistence/tests/lease.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { randomUUID } from 'node:crypto' import { + sessionLeaseOwnerIsLive, sessionLeaseProcessIsLive, sessionLiveOwner, shareSessionLiveLease, @@ -21,10 +22,14 @@ describe('process live-session lease helpers', () => { expect(first.pid).toBe(process.pid) expect(typeof first.nonce).toBe('string') expect(sessionLiveOwner()).toEqual(first) + expect(sessionLeaseOwnerIsLive(first, first)).toBe(true) + expect(sessionLeaseOwnerIsLive({ ...first, nonce: 'reused-pid' }, first)).toBe(false) expect(sessionLeaseProcessIsLive(process.pid)).toBe(true) const missing = Object.assign(new Error('missing'), { code: 'ESRCH' }) vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing }) + expect(sessionLeaseOwnerIsLive({ pid: 999_999, nonce: 'gone' }, first)).toBe(false) + vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing }) expect(sessionLeaseProcessIsLive(999_999)).toBe(false) const denied = Object.assign(new Error('denied'), { code: 'EPERM' }) vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw denied }) @@ -59,4 +64,29 @@ describe('process live-session lease helpers', () => { await expect(release()).resolves.toBeUndefined() expect(releases).toBe(2) }) + + it('waits for a final physical release before reacquiring the same key', async () => { + const key = `finalizing-${randomUUID()}` + const releaseGate = Promise.withResolvers() + const firstPhysicalRelease = vi.fn(() => releaseGate.promise) + const secondPhysicalRelease = vi.fn(() => Promise.resolve()) + const releases: Array<() => Promise> = [firstPhysicalRelease, secondPhysicalRelease] + let acquisitions = 0 + const acquire = vi.fn<() => Promise<() => Promise>>((): Promise<() => Promise> => { + const release = releases[acquisitions++] + if (release === undefined) throw new Error('unexpected physical acquisition') + return Promise.resolve(release) + }) + const first = await shareSessionLiveLease(key, acquire) + const finalizing = first() + const reacquiring = shareSessionLiveLease(key, acquire) + await Promise.resolve() + expect(acquire).toHaveBeenCalledTimes(1) + releaseGate.resolve(undefined) + await finalizing + const second = await reacquiring + expect(acquire).toHaveBeenCalledTimes(2) + await second() + expect(secondPhysicalRelease).toHaveBeenCalledTimes(1) + }) }) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index ee3e787c3f..db13d8733e 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑ `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. -`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, flushes it, stops the terminal UI, and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. +`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, and claims the target live lease before flushing the current session; a lost claim race or later recoverable failure leaves the current TUI running and releases any acquired reservation. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process while retaining the reservation, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. `resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text. diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index 76e9c9c7aa..3242f5a2a3 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -53,9 +53,6 @@ "@deepseek-ai/dsh-session-query": { "optional": true }, - "@deepseek-ai/dsh-goal": { - "optional": true - }, "@deepseek-ai/dsh-skill": { "optional": true } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 7317f3000b..90297fc478 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -77,9 +77,9 @@ import type { SessionLogSnapshot, SessionRecord, } from '@deepseek-ai/dsh-session-query' -// Side-effect type import: declaration-merges the optional `sessionPersistence` +// Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. -import type {} from '@deepseek-ai/dsh-session-persistence' +import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence' import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill' import type { FileDiff, @@ -1841,6 +1841,8 @@ export function createTuiChat( let modelOverlay: TuiOverlaySession | undefined let resumeOverlay: TuiOverlaySession | undefined let resumeInFlight = false + let resumeReservation: SessionLiveLease | undefined + let resumeReservationCommitted = false let resumeScan = 0 let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } @@ -1853,6 +1855,12 @@ export function createTuiChat( const now = (): number => runtime.now?.() ?? Date.now() const agentStatus = (): AgentStatus => agent.status const isDisposed = (): boolean => disposed + const releaseResumeReservation = async (): Promise => { + const reservation = resumeReservation + if (reservation === undefined) return + await reservation.release() + resumeReservation = undefined + } // A configured subtitle renders as a banner line; when absent, the banner has // no subtitle. The banner itself sweeps in on start (see startBannerReveal). @@ -2436,6 +2444,8 @@ export function createTuiChat( shuttingDown ??= (async () => { disposed = true overlayManager.beginShutdown() + /* v8 ignore else -- the committed branch is the non-returning exec handoff covered by the keyless PTY test */ + if (!resumeReservationCommitted) await releaseResumeReservation() contextResolution = undefined clearStatus() for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) @@ -2871,6 +2881,7 @@ export function createTuiChat( const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { if (resumeInFlight) return resumeInFlight = true + let terminalReleased = false try { const checked = await preflightResume(candidate.record.header.id) const hostHandoff = runtime.handoffResume @@ -2884,30 +2895,52 @@ export function createTuiChat( : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') return } + if (persistence === undefined) { + throw new Error('Resume is unavailable: session persistence is not mounted.') + } + resumeReservation = await persistence.claimLive(checked.record.header.id) + if (disposed) { + await releaseResumeReservation() + return + } await ctx.sessions.flush(agent.session) + // Disposal can run while the flush promise is pending; TypeScript does not model that reentry. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (disposed) return if (agent.status !== 'idle') throw new Error(`Resume requires an idle agent (status: ${agent.status}).`) await overlay.close() resumeOverlay = undefined await runtime.terminal.drainInput(100, 20) + // Disposal can run while terminal draining is pending; TypeScript does not model that reentry. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (disposed) return ui.stop() - try { - await hostHandoff(checked.record.header.id) - throw new Error('resume host returned without replacing the process') - } catch (error: unknown) { - /* v8 ignore next -- a committed host disposes this TUI and never returns; pre-commit rejection keeps it live */ - if (!disposed) { + terminalReleased = true + resumeReservationCommitted = true + await hostHandoff(checked.record.header.id) + throw new Error('resume host returned without replacing the process') + } catch (error: unknown) { + /* v8 ignore next -- a committed host disposes this TUI and never returns; recoverable rejection keeps it live */ + if (!disposed) { + resumeReservationCommitted = false + let reported = error + try { + await releaseResumeReservation() + } catch (releaseError: unknown) { + reported = new Error( + `${errorChain(error)}; target reservation release failed: ${errorChain(releaseError)}`, + ) + } + if (terminalReleased) { ui.start() ui.setFocus(editor) - appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') + appendNotice(`Resume handoff failed: ${errorChain(reported)}`, 'error') + } else { + await overlay.close() + resumeOverlay = undefined + appendNotice(`Resume failed: ${errorChain(reported)}`, 'error') } } - } catch (error: unknown) { - /* v8 ignore next -- disposal settles the overlay and suppresses late preflight diagnostics */ - if (!disposed) { - await overlay.close() - resumeOverlay = undefined - appendNotice(`Resume failed: ${errorChain(error)}`, 'error') - } } finally { resumeInFlight = false } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 6b9bd143d4..22692026e5 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -10,6 +10,7 @@ import AgentRegistry, { import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -53,6 +54,7 @@ export interface TuiHarnessOptions { list(): Promise load?(id: ReturnType): Promise<{ meta: SessionHeader; events: Session['events'] }> isLive?(id: ReturnType): Promise + claimLive?(id: ReturnType): Promise } handoffResume?: TuiRuntime['handoffResume'] /** Set false to exercise the optional session-query degradation path. */ @@ -138,7 +140,9 @@ export async function createTuiTestHarness) => Promise.reject(new Error(`session "${id}" not found`)) : (id: ReturnType) => persistence.load!(id), - claimLive: () => Promise.resolve({ release: () => Promise.resolve() }), + claimLive: persistence.claimLive === undefined + ? () => Promise.resolve({ release: () => Promise.resolve() }) + : (id: ReturnType) => persistence.claimLive!(id), isLive: persistence.isLive === undefined ? () => Promise.resolve(false) : (id: ReturnType) => persistence.isLive!(id), diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index a6f8d30341..635446b041 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -562,6 +562,8 @@ describe('resume command and /resume', () => { it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => { const target = header('target-session', 10, '/workspace') + const releaseReservation = vi.fn(() => Promise.resolve()) + const claimLive = vi.fn(async () => ({ release: releaseReservation })) const handoff = vi.fn>(() => Promise.reject(new Error('test host retained process'))) const result = await setup({ cwd: '/workspace', @@ -569,6 +571,7 @@ describe('resume command and /resume', () => { sessionPersistence: { list: async () => [target], load: async () => ({ meta: target, events: resumeEvents('Target session') }), + claimLive, }, }) result.terminal.send('/resume') @@ -579,6 +582,8 @@ describe('resume command and /resume', () => { await tick(); await tick() expect(handoff).toHaveBeenCalledTimes(1) expect(handoff).toHaveBeenCalledWith(target.id) + expect(claimLive).toHaveBeenCalledWith(target.id) + expect(releaseReservation).toHaveBeenCalledTimes(1) expect(result.terminal.stopped).toBeGreaterThan(0) expect(result.terminal.output).toContain('Resume handoff failed: test host retained process') await dispose(result) @@ -630,6 +635,183 @@ describe('resume command and /resume', () => { await dispose(result) }) + it('keeps the current TUI when the target reservation loses the preflight race', async () => { + const target = header('reservation-race', 10, '/workspace') + const handoff = vi.fn>() + const flush = vi.fn() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.on('session/flush', flush) + }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Reservation race') }), + claimLive: () => Promise.reject(new Error('occupied after preflight')), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Reservation race') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume failed: occupied after preflight') + expect(flush).not.toHaveBeenCalled() + expect(handoff).not.toHaveBeenCalled() + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + + it('refuses host handoff when a query backend has no persistence lease service', async () => { + const target = header('query-without-persistence', 10, '/workspace') + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ + header: target, + live: false, + persisted: true, + }]), + readSession: () => Promise.resolve({ + session: target, + events: resumeEvents('Query without persistence'), + }), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Query without persistence') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('session persistence is not mounted') + expect(handoff).not.toHaveBeenCalled() + await dispose(result) + }) + + it('releases a reservation that resolves after TUI disposal', async () => { + const target = header('late-reservation', 10, '/workspace') + const claiming = Promise.withResolvers<{ release(): Promise }>() + const release = vi.fn(() => Promise.resolve()) + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Late reservation') }), + claimLive: () => claiming.promise, + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Late reservation') + result.terminal.send('\r') + await tick() + await dispose(result) + claiming.resolve({ release }) + await tick() + expect(release).toHaveBeenCalledTimes(1) + expect(handoff).not.toHaveBeenCalled() + }) + + it('does not hand off after disposal begins during the current-session flush', async () => { + const target = header('dispose-during-flush', 10, '/workspace') + const flushing = Promise.withResolvers() + const release = vi.fn(() => Promise.resolve()) + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.on('session/flush', () => flushing.promise) + }, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Dispose during flush') }), + claimLive: async () => ({ release }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Dispose during flush') + result.terminal.send('\r') + await tick() + const disposing = dispose(result) + await tick() + flushing.resolve(undefined) + await disposing + expect(release).toHaveBeenCalledTimes(1) + expect(handoff).not.toHaveBeenCalled() + }) + + it('does not hand off after disposal begins while terminal input drains', async () => { + const target = header('dispose-during-drain', 10, '/workspace') + const draining = Promise.withResolvers() + const release = vi.fn(() => Promise.resolve()) + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Dispose during drain') }), + claimLive: async () => ({ release }), + }, + }) + result.terminal.drainInput.mockImplementationOnce(() => draining.promise) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Dispose during drain') + result.terminal.send('\r') + await vi.waitFor(() => { expect(result.terminal.drainInput).toHaveBeenCalled() }) + await dispose(result) + draining.resolve(undefined) + await tick() + expect(release).toHaveBeenCalledTimes(1) + expect(handoff).not.toHaveBeenCalled() + }) + + it('reports a target reservation release failure after a recoverable host rejection', async () => { + const target = header('release-failure', 10, '/workspace') + let releases = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: () => Promise.reject(new Error('host rejected')), + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Release failure') }), + claimLive: async () => ({ + release: () => ++releases === 1 + ? Promise.reject(new Error('lock unavailable')) + : Promise.resolve(), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Release failure') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('target reservation release failed') + expect(result.terminal.output).toContain('release failed: lock') + await dispose(result) + expect(releases).toBe(2) + }) + it('rejects a candidate whose cwd changes between listing and preflight', async () => { const target = header('moving-workspace', 10, '/workspace') let listings = 0 From c440217fde2e5355c1ec44b3ade1ab2301fc7816 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 16:07:49 +0800 Subject: [PATCH 11/15] refactor(tui): defer cross-process resume locking --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 14 +- .../2026-07-21-tui-resume-command.zh.md | 14 +- apps/cli/README.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.md | 6 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 24 +-- docs/core-data-structures/persistence.md | 12 -- docs/event-producer-consumer.md | 2 +- examples/tui-agent/README.md | 2 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 -- packages/core/agent-loop/src/index.ts | 31 +-- packages/core/agent-loop/tests/resume.spec.ts | 45 ----- packages/examples/tui-demo/README.md | 2 +- .../session-persistence-jsonl/README.md | 8 +- .../session-persistence-jsonl/src/index.ts | 122 +---------- .../tests/fixtures/live-lease-child.ts | 16 -- .../tests/fixtures/live-lease-race-child.ts | 33 --- .../tests/jsonl.spec.ts | 189 +----------------- .../session-persistence-sqlite/README.md | 5 +- .../session-persistence-sqlite/src/index.ts | 67 +------ .../session-persistence-sqlite/src/schema.ts | 11 +- .../tests/sqlite.spec.ts | 51 +---- .../session-persistence/README.md | 9 +- .../session-persistence/src/coordinator.ts | 86 +------- .../session-persistence/src/index.ts | 43 ---- .../session-persistence/src/lease.ts | 123 ------------ .../session-persistence/tests/lease.spec.ts | 92 --------- .../tests/persistence.spec.ts | 58 +----- packages/ui/tui/README.md | 3 +- packages/ui/tui/src/index.ts | 51 +---- packages/ui/tui/tests/harness.ts | 9 - packages/ui/tui/tests/tui.spec.ts | 152 +++++++------- scripts/gen-cordis-catalog.ts | 1 - scripts/type-equiv.manifest.json | 5 - 39 files changed, 133 insertions(+), 1183 deletions(-) delete mode 100644 packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts delete mode 100644 packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts delete mode 100644 packages/session-persistence/session-persistence/src/lease.ts delete mode 100644 packages/session-persistence/session-persistence/tests/lease.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index c04f198e4a..62dc61c019 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: cd08b56f1e887473fd1df9f5f6055cb7c5e0a9b4 -2026-07-21-tui-resume-command.zh.md: ef641292dd178e19f94b6f79d3ab8607da27ee6d +2026-07-21-tui-resume-command.md: 526c3775bcae1bae63fb37b097091f83cfc67afd +2026-07-21-tui-resume-command.zh.md: d333a5bb22057d3d035c22950a84f51e0ca0640d diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index cd08b56f1e..526c3775bc 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -6,17 +6,15 @@ English | [中文](2026-07-21-tui-resume-command.zh.md) ## Problem -The original `/resume` printed shell commands. It did not let a keyboard user inspect titles or outcomes, distinguish corruption from a missing adapter, detect another live owner, or safely transfer the terminal. Leaving the TUI and manually launching a command also hid the required ordering: finish current work, flush it, release the UI and app, then restore the exact persisted identity without silently creating a replacement. +The original `/resume` printed shell commands. It did not let a keyboard user inspect titles or outcomes, distinguish corruption from a missing adapter, or safely transfer the terminal. Leaving the TUI and manually launching a command also hid the required ordering: finish current work, flush it, release the UI and app, then restore the exact persisted identity without silently creating a replacement. ## Decision -`/resume` uses the TUI's existing interactive overlay seam. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and another live owner's session remain visible but disabled. +`/resume` uses the TUI's existing interactive overlay seam. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. -`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate repeats the load, cwd, occupancy, and route checks so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. Running agents are never switched or cancelled implicitly. +`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, `cwd`, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process. -First-party persistence backends implement a cross-process live lease under the shared coordinator. JSONL uses an owner-only lock record; SQLite uses a `live_session_leases` row. Both retain PID plus an exec-stable nonce, reject another live process, reclaim a dead PID or a same-PID different-incarnation owner, and release only after the exact session lifecycle drains. A final process-local release excludes reacquisition until the physical lease settles. `AgentLoop.resume()` claims before load, closing the preflight/start race. - -After preflight, the TUI claims the target's exec-stable live lease before flushing the current session. A lost claim race remains in the current TUI; any later recoverable failure releases the reservation. The TUI then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process while retaining the target reservation rather than spawning a second terminal owner. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. +After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. `resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable. @@ -32,10 +30,10 @@ After preflight, the TUI claims the target's exec-stable live lease before flush ## Consequences -- Persistence schema and artifact layout include live leases; SQLite advances its unreleased schema version and rejects older databases under the repository's pre-release policy. +- Concurrent processes can select or resume the same persisted session because preflight does not serialize them. - `/resume` depends on `session-query` for discovery and complete-log reads, but persistence and host handoff remain optional; without a host, the command fallback stays usable. - Process replacement intentionally restarts Loader composition. Runtime-only state is rebuilt, while only logged or header-backed session state survives. ## Testing -TUI tests cover keyboard navigation, title/id search, Escape cancellation, running-agent refusal, route absence, occupied and corrupt rows, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Persistence contracts retain valid/corrupt/interrupted behavior, while a real JSONL child process proves another owner is disabled and its crashed lease is reclaimed. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the visible selector frame. +TUI tests cover keyboard navigation, title/id search, Escape cancellation, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the visible selector frame. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index ef641292dd..d333a5bb22 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -6,17 +6,15 @@ Status: implemented ## Problem -原有 `/resume` 只会打印 shell 命令。使用键盘操作的用户无法查看标题或结果、区分日志损坏与适配器缺失、发现另一个活跃所有者,也无法安全移交终端。退出 TUI 后手动启动命令还掩盖了必要的操作顺序:等待当前工作结束并将其刷写,释放 UI 和应用,再恢复持久化的原有身份,绝不能静默创建替代会话。 +原有 `/resume` 只会打印 shell 命令。使用键盘操作的用户无法查看标题或结果、区分日志损坏与适配器缺失,也无法安全移交终端。退出 TUI 后手动启动命令还掩盖了必要的操作顺序:等待当前工作结束并将其刷写,释放 UI 和应用,再恢复持久化的原有身份,绝不能静默创建替代会话。 ## Decision -`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和被另一个活跃进程占用的会话仍会显示,但不可选择。 +`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 -`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会再次检查日志加载、cwd、占用情况和路由,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。系统绝不会隐式切换或取消处于运行状态的 agent。 +`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。 -第一方持久化后端通过共享协调器实现跨进程的活跃会话租约。JSONL 使用所有者专属的锁记录;SQLite 使用一条 `live_session_leases` 记录。两者都保存 PID 以及进程替换前后保持稳定的随机标记,拒绝其他活跃进程领取租约,回收已终止 PID 或 PID 相同但进程代际不同的租约,并且仅在对应会话生命周期完全停稳后释放租约。进程内最后一个引用开始释放后,新的领取操作必须等待物理租约完成释放再重新获取。`AgentLoop.resume()` 在加载前领取租约,消除预检与启动之间的竞态。 - -预检通过后,TUI 会先领取目标会话在进程替换前后保持稳定的活跃租约,再刷写当前会话。如果目标在预检后被其他进程抢占,当前 TUI 会继续运行;之后任何可恢复失败也会释放该预留租约。随后 TUI 停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,同时保留目标预留租约,而不会创建第二个终端所有者。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 +预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 `resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。 @@ -32,10 +30,10 @@ Status: implemented ## Consequences -- 持久化 schema 和产物布局均包含活跃会话租约;SQLite 会推进其尚未发布的 schema 版本,并根据仓库的预发布政策拒绝旧数据库。 +- 预检不会串行化不同进程;多个进程可以并发选择或恢复同一个持久化会话。 - `/resume` 依赖 `session-query` 发现会话并读取完整日志,但持久化和宿主交接仍是可选功能;没有宿主时,命令回退仍可使用。 - 进程替换会有意重启 Loader 组合。系统会重建仅存在于运行时的状态,而只有日志或会话头部记录的会话状态能够保留。 ## Testing -TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、agent 运行期间拒绝恢复、路由缺失、被占用或损坏的候选行、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。持久化契约继续覆盖有效、损坏和中断的会话;真实 JSONL 子进程则证明另一个所有者占用的会话不可选择,并且进程崩溃后遗留的租约可以回收。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。 +TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。 diff --git a/apps/cli/README.md b/apps/cli/README.md index 86bda3fbf5..e6a33247ca 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -5,7 +5,7 @@ The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are pro The TUI surface: - boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); -- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and atomically replaces the process with a normalized resume flag so only one runtime owns the terminal; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session; +- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume flag; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ea83179477..466a96dd30 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: f0ce115d0b6e07a14d3c28288ea78f2c2f4294e7 -architecture.zh.md: eef66e3b9df6a3f00a48fc2c6d2e942b3fa9fe42 +architecture.md: 5a0ff63413a0c2a59d042f935d341dd39234f669 +architecture.zh.md: e1fb143982ad968fe6be2a5f6154722a602a297b diff --git a/docs/architecture.md b/docs/architecture.md index f0ce115d0b..5a0ff63413 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,7 +67,7 @@ The shipped loop runs prompt-to-checkpoint work through plugin services and even A **session** is append-only. Each ordinary **turn** claims one queued `send()` item; injection claims none. A successor awaits the preceding claimed turn's checkpoint but may share its `running` interval ([decision](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md)). A turn ends when model and plugins stop it; a **step** is one model request plus tools. In the [sequence below](agent-lifecycle.md), quotes mark durable events. -Creation without an id mints `-session-`; `sessionId` restores-or-creates, while `resumeSessionId` requires history. Resume claims a live lease before load, restores lineage and delegation depth before publication, and releases after quiescence. Startup failures emit `agent-loop/config-start-failed`; teardown is otherwise silent. +Creation without an id mints `-session-`; `sessionId` restores-or-creates, while `resumeSessionId` requires history. Resume restores lineage and delegation depth before publication. Startup failures emit `agent-loop/config-start-failed`; teardown is otherwise silent. ### Turn Flow diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index eef66e3b9d..e1fb143982 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -67,7 +67,7 @@ waterfall(瀑布式事件)的行为类似环绕中间件:监听器调用 ` **会话**采用仅追加方式。每个普通**轮次**领取一项已排队的 `send()` 输入;注入不领取输入。后续轮次会等待前一个已领取轮次的检查点,但可以与其共用同一个 `running` 区间([决策](../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md))。模型和插件停止轮次时,该轮次结束;一个**步骤**包含一次模型请求及其工具。在[下文时序](agent-lifecycle.md)中,引号标记持久事件。 -未提供 id 时会生成 `-session-`;`sessionId` 用于恢复或创建,而 `resumeSessionId` 要求已有历史。恢复流程在加载前领取活跃会话租约,在发布前还原沿袭关系和委托深度,并在系统停稳后释放租约。初始化失败会发出 `agent-loop/config-start-failed`;其余拆卸过程保持静默。 +未提供 id 时会生成 `-session-`;`sessionId` 用于恢复或创建,而 `resumeSessionId` 要求已有历史。恢复流程会在发布前还原沿袭关系和委托深度。初始化失败会发出 `agent-loop/config-start-failed`;其余拆卸过程保持静默。 ### 轮次流程 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6b841214c5..040b15baf3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -113,7 +113,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:378`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:360`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -957,7 +957,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:40`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) +Source: [`packages/session-persistence/session-persistence-jsonl/src/index.ts:39`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -996,7 +996,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:59`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:58`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query-sqlite` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 9bda12f6b0..6becfd9434 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -399,7 +399,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:371`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:353`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f0ee05d705..5858799e7c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise - -/** - * Atomically acquire this process's live ownership of a session id. - * Reentrant claims share one backend lease. First-party backends override - * this process-local fallback to reject another live process and reclaim a - * dead owner. - * @param id - session identity that is about to become live. - * @returns a single-release reference owned by the caller. - */ -claimLive(id: SessionId): Promise - -/** - * Check whether any process currently owns a live lease for this session. - * The base implementation reports only claims on this service instance. - * @param id - persisted or prospective session identity. - * @returns true while a non-stale lease exists, including this process's lease. - */ -isLive(id: SessionId): Promise ``` -Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLiveLease](../core-data-structures/persistence.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) +Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) -Source: [`packages/session-persistence/session-persistence/src/index.ts:60`](../../packages/session-persistence/session-persistence/src/index.ts) +Source: [`packages/session-persistence/session-persistence/src/index.ts:52`](../../packages/session-persistence/session-persistence/src/index.ts) ## `ctx.sessionQuery` — `SessionQueryService` (abstract seam) diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index ffd4304376..f45eb0417a 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -34,18 +34,6 @@ interface SessionLocation { } ``` -## `SessionLiveLease` — live ownership capability - -`claimLive(id)` returns one idempotent release capability. The base service tracks only its own process; first-party backends additionally reject another live process and reclaim a dead owner's lease. `isLive(id)` reports either local or backend ownership without claiming it. - -```ts type-equiv -/** Idempotent capability releasing one acquired live-session lease reference. */ -interface SessionLiveLease { - /** Release this caller's lease reference after its live session reaches quiescence. */ - release(): Promise -} -``` - ## `SessionHeader` — metadata beside the log Per-session metadata travels **separately** from the event log: format version, cwd, lineage, and the seed boundary are storage concerns, not conversation events, so they stay out of `SessionEventMap` and never reach `deriveMessages()`. The header is attached to a `Session` via `session.header`. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 7f7ecc4f2d..179ad0dcee 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:371`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:217`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:188`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 032a82bdaf..2e87df0a27 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -27,7 +27,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio dsh --resume ``` -`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then atomically replaces the process with `dsh --resume `; the terminal never has two owners. The TUI still prints that command on exit and shows it when a custom host cannot hand off. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID= pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. +`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `. The TUI still prints that command on exit and shows it when a custom host cannot hand off. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID= pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. ## Code Mode diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 9c198870ad..8c93a004c2 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -260,7 +260,7 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { }) describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { - it('hands /resume to one exec-replaced terminal owner and restores the same session state', async () => { + it('exec-replaces the TUI for /resume and restores the same session state', async () => { const output = await smoke({ label: 'dsh in-place resume', tempDirPrefix: 'dsh-in-place-resume-', diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index f49cc75b1c..6939ec9927 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -480,14 +480,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract listSnapshots(): Promise', jsDoc: '/**\n * List materialized sessions with cheap per-log change tokens.\n *\n * Repeated observations of an unchanged log return the same revision. A\n * successful mutating {@link load} repair changes the next listed revision.\n * Revisions also distinguish independently backed stores so backend-local\n * counters cannot compare equal across different persistence sources.\n * @returns one header and opaque revision per materialized session without loading full logs.\n */', }, - { - signature: 'claimLive(id: SessionId): Promise', - jsDoc: '/**\n * Atomically acquire this process\'s live ownership of a session id.\n * Reentrant claims share one backend lease. First-party backends override\n * this process-local fallback to reject another live process and reclaim a\n * dead owner.\n * @param id - session identity that is about to become live.\n * @returns a single-release reference owned by the caller.\n */', - }, - { - signature: 'isLive(id: SessionId): Promise', - jsDoc: '/**\n * Check whether any process currently owns a live lease for this session.\n * The base implementation reports only claims on this service instance.\n * @param id - persisted or prospective session identity.\n * @returns true while a non-stale lease exists, including this process\'s lease.\n */', - }, ], }, { @@ -1817,10 +1809,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionLineageTrace', declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});', }, - { - name: 'SessionLiveLease', - declaration: 'export interface SessionLiveLease {\n release(): Promise;\n}', - }, { name: 'SessionLocation', declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}', diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 15ece492e3..bdaa3f2401 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -25,7 +25,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' -import type { SessionLiveLease, SessionPersistence } from '@deepseek-ai/dsh-session-persistence' +import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { bindReactLoopAgentContext, prepareReactLoopAgent, @@ -114,7 +114,6 @@ class AgentCreationTransaction { private scope: Scope | undefined private session: Session | undefined private lifecycleDispose: (() => Promise | void) | undefined - private liveLease: SessionLiveLease | undefined private detachSession: (() => void) | undefined private detachAgent: (() => void) | undefined private publishing = false @@ -187,12 +186,6 @@ class AgentCreationTransaction { ]) } - /** Retain a pre-load persistence lease until this transaction fully tears down. */ - holdLiveLease(lease: SessionLiveLease): void { - this.assertActive() - this.liveLease = lease - } - /** Construct the driver and scope, then install their complete ordered lifecycle. */ prepare(options: AgentOptions, session: Session, maxParallelToolCalls: number): ReactLoopAgent { this.assertActive() @@ -226,11 +219,6 @@ class AgentCreationTransaction { // First yielded, disposed last. yield () => { this.finish() } yield scope.rawDispose - yield async () => { - const lease = this.liveLease - this.liveLease = undefined - await lease?.release() - } yield () => { this.detachSession?.() this.detachSession = undefined @@ -327,13 +315,7 @@ class AgentCreationTransaction { try { await this.scope?.dispose() } finally { - try { - const lease = this.liveLease - this.liveLease = undefined - await lease?.release() - } finally { - this.finish() - } + this.finish() } } })()) @@ -625,15 +607,6 @@ export class AgentLoop extends Service implements AgentFactory { options.signal, ) try { - const claiming = persistence.claimLive(options.resumeSessionId) - let lease: SessionLiveLease - try { - lease = await transaction.waitFor(claiming) - } catch (error) { - void claiming.then(claim => claim.release(), () => {}) - throw error - } - transaction.holdLiveLease(lease) const loaded = await transaction.waitFor(persistence.load(options.resumeSessionId)) transaction.assertActive() const session = this.runtime.ctx.sessions.prepare(options.resumeSessionId, { diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 2d19db7a0d..fdf5c39514 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -391,51 +391,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume', await ctx.fiber.dispose() }) - it('owner unload during live-lease acquisition releases a late claim', async () => { - const sessionId = SessionId('resume-claim-owner-unload') - const root = await persistSession(sessionId) - const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) - const claiming = Promise.withResolvers>>() - const claimStarted = Promise.withResolvers() - const originalClaim = ctx.sessionPersistence.claimLive.bind(ctx.sessionPersistence) - ctx.sessionPersistence.claimLive = (id) => { - expect(id).toBe(sessionId) - claimStarted.resolve(undefined) - return claiming.promise - } - - let resuming!: ReturnType - const owner = await ctx.plugin(Object.assign((inner: Context) => { - resuming = inner.agents.resume({ resumeSessionId: sessionId }) - }, { inject: ['agents'] })) - await claimStarted.promise - const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/) - await promptly(owner.dispose()) - await rejection - - let releases = 0 - claiming.resolve({ release: () => { releases += 1; return Promise.resolve() } }) - await Promise.resolve() - await Promise.resolve() - expect(releases).toBe(1) - ctx.sessionPersistence.claimLive = originalClaim - await ctx.fiber.dispose() - }) - - it('propagates a rejected live-lease claim without loading or publishing', async () => { - const sessionId = SessionId('resume-claim-rejected') - const root = await persistSession(sessionId) - const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) - let loads = 0 - ctx.sessionPersistence.claimLive = () => Promise.reject(new Error('occupied elsewhere')) - ctx.sessionPersistence.load = () => { loads += 1; return Promise.reject(new Error('must not load')) } - await expect(ctx.agents.resume({ resumeSessionId: sessionId })) - .rejects.toThrow('occupied elsewhere') - expect(loads).toBe(0) - expect(ctx.agents.get(sessionId)).toBeUndefined() - await ctx.fiber.dispose() - }) - it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => { const sessionId = SessionId('resume-load-factory-unload') const root = await persistSession(sessionId) diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index bba7ae7f7e..10ff7872c5 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -45,7 +45,7 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le | `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height | | `resumeSessionId` | — | Exact persisted session to resume | -Fresh runs mint a `main-session-` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for safe in-place process handoff. +Fresh runs mint a `main-session-` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff. ## The bin diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index 86a8c0f1d6..bf86bf8633 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -6,9 +6,6 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence ``` / - .live/ - .lock # PID + nonce cross-process live lease - .lock.reclaim # ephemeral stale-owner takeover guard cwd-/ # per-project bucket (or _no-cwd/ when no cwd) .jsonl.zstd # default: checksummed header frame + append frames .jsonl # only with compression: 'none' @@ -46,7 +43,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the ## Write path -The plugin copies frozen session events into one controller per live session and starts an eager drain. Before a session can flush or resume, the coordinator claims an exclusive `.live/.lock` containing the process PID and an exec-stable nonce; another live process is rejected, while a dead owner is reclaimed under the separate `.reclaim` guard. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. Disposal drains every retained controller before releasing its lease. +The plugin copies frozen session events into one controller per live session and starts an eager drain. Concurrent events share the current write; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. A per-session cursor prevents resumed sessions from re-appending stored events, and live sessions are seeded when the plugin loads. The owning backend instance serializes operations for one session; disposal drains every retained controller before teardown. ## Model Experience @@ -69,6 +66,5 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr - **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration. - **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required. - **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface). -- **Lease scope is local-host advisory ownership** — PID plus same-process nonce checks prevent two ordinary local Harness processes from resuming the same id, but foreign PID reuse remains fail-closed and this is not a distributed lease for shared network filesystems or hostile principals. -- **A crash during stale-lease takeover fails closed** — if the reclaiming process itself crashes while holding the short-lived `.reclaim` guard, an operator must remove that guard after confirming no recovery is active. +- **One live writer per session** — append and repair are coordinated only inside the owning backend instance. Another backend instance or process must not write the same session until that owner reaches quiescent disposal; initial same-id publication remains collision-safe through the POSIX no-overwrite hard link or Windows write-through rename without replacement. - **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement. diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 8d96a17681..629c0e3ff1 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -14,9 +14,8 @@ import { dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - sessionLeaseOwnerIsLive, shareSessionLiveLease, - type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, - type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, + type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, + type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -65,11 +64,6 @@ interface JsonlTornMarker { recoveredEvents: SessionEvent[] } -interface JsonlLiveLeaseRecord { - pid: number - nonce: string -} - /** Whether a filesystem error means absence; every non-ENOENT failure must surface. */ function isENOENT(error: unknown): boolean { return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT' @@ -141,14 +135,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.inspect(id) } - override claimLive(id: SessionId): Promise { - return this.coordinator.claimLive(id) - } - - override isLive(id: SessionId): Promise { - return this.coordinator.isLive(id) - } - // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. @@ -288,110 +274,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return snapshots } - /** Atomically publish one process lease, reclaiming a crashed owner's record. */ - async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise> { - const path = this.liveLeasePath(id) - return shareSessionLiveLease(`jsonl:${path}`, () => this.acquireLiveFile(path, id, owner)) - } - - private async acquireLiveFile( - path: string, - id: SessionId, - owner: SessionLiveOwner, - ): Promise<() => Promise> { - await mkdir(dirname(path), { recursive: true, mode: 0o700 }) - for (;;) { - try { - const handle = await open(path, 'wx', 0o600) - try { - await handle.writeFile(`${JSON.stringify(owner)}\n`, 'utf8') - await handle.sync() - } finally { - await handle.close() - } - break - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error - const current = await this.readLiveLease(path) - if (current !== undefined && current.pid === owner.pid && current.nonce === owner.nonce) break - if (current === undefined || sessionLeaseOwnerIsLive(current, owner)) { - throw new Error(`session "${id}" is occupied by another live process`) - } - const reclaimPath = `${path}.reclaim` - let reclaim: Awaited> - try { - reclaim = await open(reclaimPath, 'wx', 0o600) - } catch (reclaimError) { - /* v8 ignore else -- non-contention filesystem failures are propagated verbatim and are not portable to induce */ - if ((reclaimError as NodeJS.ErrnoException).code === 'EEXIST') { - throw new Error(`session "${id}" live-lease reclamation is already in progress`) - } - /* v8 ignore next -- non-contention filesystem failures are propagated verbatim and are not portable to induce */ - throw reclaimError - } - try { - /* v8 ignore start -- cross-process revalidation is covered by the two-process race test */ - const latest = await this.readLiveLease(path) - if (latest === undefined) { - if (await this.exists(path)) throw new Error(`session "${id}" has an unreadable live-process lease`) - } else if (latest.pid !== owner.pid || latest.nonce !== owner.nonce) { - if (sessionLeaseOwnerIsLive(latest, owner)) { - throw new Error(`session "${id}" is occupied by another live process`) - } - await rm(path, { force: true }) - } - /* v8 ignore stop */ - } finally { - try { - await reclaim.close() - } finally { - await rm(reclaimPath, { force: true }) - } - } - } - } - return async () => { - const current = await this.readLiveLease(path) - if (current?.pid === owner.pid && current.nonce === owner.nonce) await rm(path, { force: true }) - } - } - - /** Report one non-stale process lease; acquisition reclaims a crashed owner's record. */ - async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise { - const path = this.liveLeasePath(id) - const current = await this.readLiveLease(path) - if (current === undefined) return await this.exists(path) - if (current.pid === owner.pid && current.nonce === owner.nonce) return true - if (sessionLeaseOwnerIsLive(current, owner)) return true - return false - } - - private liveLeasePath(id: SessionId): string { - return join(this.root, '.live', `${encodeSegment(id)}.lock`) - } - - private async readLiveLease(path: string): Promise { - let text: string - try { - text = await readFile(path, 'utf8') - } catch (error) { - if (isENOENT(error)) return undefined - throw error - } - let value: unknown - try { - value = JSON.parse(text) - } catch { - return undefined - } - if (typeof value !== 'object' || value === null - || !Number.isSafeInteger((value as { pid?: unknown }).pid) - || (value as { pid: number }).pid <= 0 - || typeof (value as { nonce?: unknown }).nonce !== 'string' - || (value as { nonce: string }).nonce.length === 0) return undefined - return value as JsonlLiveLeaseRecord - } - private async listArtifacts(): Promise> { await this.ensureRootEncoding() const artifacts: Array<{ header: SessionHeader; path: string }> = [] diff --git a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts deleted file mode 100644 index 2a42b8c402..0000000000 --- a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-child.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** Child process that holds one JSONL live-session lease until it is killed. */ - -import { writeFile } from 'node:fs/promises' -import { Context } from 'cordis' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' - -const [root, marker] = process.argv.slice(2) -if (root === undefined || marker === undefined) throw new Error('usage: live-lease-child.ts ') - -const ctx = new Context() -await ctx.plugin(SessionStore) -await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) -await ctx.sessionPersistence.claimLive(SessionId('leased-session')) -await writeFile(marker, 'held') -await new Promise(() => { setInterval(() => {}, 60_000) }) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts b/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts deleted file mode 100644 index b4ef3b6e59..0000000000 --- a/packages/session-persistence/session-persistence-jsonl/tests/fixtures/live-lease-race-child.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** Child process competing to reclaim one stale JSONL live-session lease. */ - -import { access, writeFile } from 'node:fs/promises' -import { Context } from 'cordis' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' - -const [root, gate, marker, rawId] = process.argv.slice(2) -if (root === undefined || gate === undefined || marker === undefined || rawId === undefined) { - throw new Error('usage: live-lease-race-child.ts ') -} - -for (;;) { - try { - await access(gate) - break - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - await new Promise(resolve => setTimeout(resolve, 5)) - } -} - -const ctx = new Context() -await ctx.plugin(SessionStore) -await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) -try { - await ctx.sessionPersistence.claimLive(SessionId(rawId)) - await writeFile(marker, 'claimed') - await new Promise(() => { setInterval(() => {}, 60_000) }) -} catch (error) { - await writeFile(marker, `rejected:${error instanceof Error ? error.message : String(error)}`) - await ctx.fiber.dispose() -} diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 269d56f886..2b49b7d55b 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1,24 +1,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { spawn } from 'node:child_process' import { Context } from 'cordis' -import { access, appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' +import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { isAbsolute, join, relative, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence' import { encodeSegment, eventLines, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' let root: string const dirs: string[] = [] -const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) -const leaseChild = fileURLToPath(new URL('./fixtures/live-lease-child.ts', import.meta.url)) -const leaseRaceChild = fileURLToPath(new URL('./fixtures/live-lease-race-child.ts', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] } @@ -149,186 +142,6 @@ describe('SessionPersistenceJsonl: format helpers', () => { }) }) -describe('SessionPersistenceJsonl: cross-process live leases', () => { - it('reference-counts one physical lease across backend instances in the process', async () => { - const dir = await freshRoot() - const contexts = [new Context(), new Context()] - for (const ctx of contexts) { - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) - } - try { - const first = await contexts[0]!.sessionPersistence.claimLive(SessionId('shared-live')) - const second = await contexts[1]!.sessionPersistence.claimLive(SessionId('shared-live')) - await first.release() - await expect(contexts[1]!.sessionPersistence.isLive(SessionId('shared-live'))).resolves.toBe(true) - await second.release() - await expect(contexts[1]!.sessionPersistence.isLive(SessionId('shared-live'))).resolves.toBe(false) - } finally { - await Promise.all(contexts.map(ctx => ctx.fiber.dispose())) - } - }) - - it('disables another live owner and reclaims its lease after the process exits', async () => { - const dir = await freshRoot() - const marker = join(dir, 'lease-held') - const child = spawn(process.execPath, ['--import', tsxLoader, leaseChild, dir, marker], { - cwd: repoRoot, - env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, - stdio: ['ignore', 'ignore', 'pipe'], - }) - let stderr = '' - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { stderr += chunk }) - try { - await vi.waitFor(() => access(marker), { timeout: 30_000 }) - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) - try { - await expect(ctx.sessionPersistence.isLive(SessionId('leased-session'))).resolves.toBe(true) - await expect(ctx.sessionPersistence.claimLive(SessionId('leased-session'))) - .rejects.toThrow('occupied by another live process') - const closed = new Promise(resolve => child.once('close', () => { resolve() })) - child.kill() - await closed - await expect(ctx.sessionPersistence.isLive(SessionId('leased-session'))).resolves.toBe(false) - const leasePath = join(dir, '.live', `${encodeSegment('leased-session')}.lock`) - await writeFile(leasePath, `${JSON.stringify({ pid: child.pid, nonce: 'dead-owner' })}\n`) - const claim = await ctx.sessionPersistence.claimLive(SessionId('leased-session')) - await claim.release() - } finally { - await ctx.fiber.dispose() - } - } catch (error) { - throw new Error(`live-lease child failed: ${stderr}`, { cause: error }) - } finally { - if (child.exitCode === null && child.signalCode === null) child.kill() - } - }, 40_000) - - it('fails closed on malformed lease records and surfaces lease read errors', async () => { - const dir = await freshRoot() - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) - const liveDir = join(dir, '.live') - await mkdir(liveDir, { recursive: true }) - try { - const malformed = [ - 'not json', - JSON.stringify(null), - JSON.stringify({ pid: 1.5, nonce: 'x' }), - JSON.stringify({ pid: 0, nonce: 'x' }), - JSON.stringify({ pid: process.pid, nonce: 1 }), - JSON.stringify({ pid: process.pid, nonce: '' }), - ] - for (const [index, content] of malformed.entries()) { - const id = SessionId(`malformed-${index}`) - const path = join(liveDir, `${encodeSegment(id)}.lock`) - await writeFile(path, content) - await expect(ctx.sessionPersistence.isLive(id)).resolves.toBe(true) - await expect(ctx.sessionPersistence.claimLive(id)).rejects.toThrow('occupied by another live process') - } - - const unreadable = SessionId('unreadable-lease') - await mkdir(join(liveDir, `${encodeSegment(unreadable)}.lock`)) - await expect(ctx.sessionPersistence.isLive(unreadable)).rejects.toThrow() - - const replaced = SessionId('replaced-release') - const claim = await ctx.sessionPersistence.claimLive(replaced) - const replacedPath = join(liveDir, `${encodeSegment(replaced)}.lock`) - await writeFile(replacedPath, JSON.stringify({ pid: process.pid, nonce: 'replacement' })) - await claim.release() - expect(await readFile(replacedPath, 'utf8')).toContain('replacement') - - const inherited = SessionId('inherited-owner') - const inheritedPath = join(liveDir, `${encodeSegment(inherited)}.lock`) - await writeFile(inheritedPath, JSON.stringify(sessionLiveOwner())) - await expect(ctx.sessionPersistence.isLive(inherited)).resolves.toBe(true) - const inheritedClaim = await ctx.sessionPersistence.claimLive(inherited) - await inheritedClaim.release() - - const reusedPid = SessionId('reused-pid') - const reusedPidPath = join(liveDir, `${encodeSegment(reusedPid)}.lock`) - await writeFile(reusedPidPath, JSON.stringify({ pid: process.pid, nonce: 'prior-incarnation' })) - await expect(ctx.sessionPersistence.isLive(reusedPid)).resolves.toBe(false) - const reusedPidClaim = await ctx.sessionPersistence.claimLive(reusedPid) - await reusedPidClaim.release() - - await expect(ctx.sessionPersistence.claimLive(SessionId('x'.repeat(300)))) - .rejects.toThrow() - - const guarded = SessionId('guarded-reclaim') - const guardedPath = join(liveDir, `${encodeSegment(guarded)}.lock`) - await writeFile(guardedPath, JSON.stringify({ pid: 2_147_483_647, nonce: 'dead-owner' })) - await writeFile(`${guardedPath}.reclaim`, 'busy') - await expect(ctx.sessionPersistence.claimLive(guarded)) - .rejects.toThrow('reclamation is already in progress') - } finally { - await ctx.fiber.dispose() - } - }) - - it('allows exactly one process to reclaim a stale lease', async () => { - const dir = await freshRoot() - const liveDir = join(dir, '.live') - await mkdir(liveDir, { recursive: true }) - const sessionId = SessionId('reclaim-race') - await writeFile( - join(liveDir, `${encodeSegment(sessionId)}.lock`), - JSON.stringify({ pid: 2_147_483_647, nonce: 'dead-owner' }), - ) - const gate = join(dir, 'race-start') - const markers = [join(dir, 'race-a'), join(dir, 'race-b')] - const children = markers.map(marker => spawn( - process.execPath, - ['--import', tsxLoader, leaseRaceChild, dir, gate, marker, sessionId], - { - cwd: repoRoot, - env: { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') }, - stdio: ['ignore', 'ignore', 'pipe'], - }, - )) - const errors = ['', ''] - children.forEach((child, index) => { - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => { errors[index] = (errors[index] ?? '') + chunk }) - }) - try { - await writeFile(gate, 'go') - await vi.waitFor(() => Promise.all(markers.map(marker => access(marker))), { timeout: 30_000 }) - const outcomes = await Promise.all(markers.map(marker => readFile(marker, 'utf8'))) - expect(outcomes.filter(outcome => outcome === 'claimed')).toHaveLength(1) - expect(outcomes.filter(outcome => outcome.startsWith('rejected:'))).toHaveLength(1) - - const winner = children[outcomes.findIndex(outcome => outcome === 'claimed')]! - const loser = children[outcomes.findIndex(outcome => outcome.startsWith('rejected:'))]! - if (loser.exitCode === null && loser.signalCode === null) { - await new Promise(resolve => loser.once('close', () => { resolve() })) - } - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' }) - try { - await expect(ctx.sessionPersistence.claimLive(sessionId)) - .rejects.toThrow('occupied by another live process') - } finally { - await ctx.fiber.dispose() - } - const closed = new Promise(resolve => winner.once('close', () => { resolve() })) - winner.kill() - await closed - } catch (error) { - throw new Error(`live-lease race children failed: ${errors.join('\n')}`, { cause: error }) - } finally { - for (const child of children) { - if (child.exitCode === null && child.signalCode === null) child.kill() - } - } - }, 40_000) -}) - describe('SessionPersistenceJsonl: durability and crash semantics', () => { let ctx: Context beforeEach(async () => { diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 42421b2e81..f1f4bc1f7b 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i ## Storage model -Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id, and `live_session_leases` stores one PID and exec-stable nonce per live session. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). +Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../.agents/notes/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`), a per-materialization incarnation id, and a monotonic per-log revision live in a `sessions` row; a singleton state row carries the immutable store id. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row). The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. @@ -33,7 +33,7 @@ interface Config { ## Write path -Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. A live lease is acquired in a `BEGIN IMMEDIATE` transaction before flush or resume and released after the exact lifecycle retires. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. +Like the JSONL backend, the plugin copies each frozen `session/event` into one controller per live session and starts an eager drain. Concurrent events share the current transaction; events admitted during it form a follow-up batch, while `session/flush` waits until both current and pending batches are durable. The controller persists a fork's seed once, keeps a write cursor so resume never re-appends stored events, and seeds live sessions on apply because HMR does not replay `session/created`. Dispose drains every retained controller before closing the database. ## Model Experience @@ -57,4 +57,3 @@ SQLite storage does not mutate live request prefixes. A resumed loop can reuse p - **Write contention has no wait or retry policy** — the backend sets no busy timeout and retries no locked-database error, so another connection holding a write transaction makes the operation reject immediately. - **Only the current `SCHEMA_VERSION` opens** — a database with any other schema version is rejected rather than migrated (unreleased software; no persisted user data to preserve). - **Nothing deletes stored sessions** — rows accumulate until removed externally (the seam has no deletion surface; `ON DELETE CASCADE` is wired for such out-of-band cleanup). -- **Foreign PID reuse is fail-closed** — same-PID claimants compare the exec-stable nonce, while other processes conservatively retain a stale row until the reused PID exits or an operator verifies and removes it. diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 091880e87e..5804c18282 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -15,9 +15,8 @@ import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - sessionLeaseOwnerIsLive, shareSessionLiveLease, - type PersistenceBackend, type SessionLiveLease, type SessionLiveOwner, - type SessionLocation, type SessionPersistenceSnapshot, type StoredPrefix, + type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, + type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -162,14 +161,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.inspect(id) } - override claimLive(id: SessionId): Promise { - return this.coordinator.claimLive(id) - } - - override isLive(id: SessionId): Promise { - return this.coordinator.isLive(id) - } - // One method serves both public `list` and the backend hook; delegating it to // the coordinator would call this hook recursively. @@ -280,55 +271,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers })) } - /** Atomically acquire one SQLite-backed process lease. */ - async acquireLive(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise> { - await this.ready - return shareSessionLiveLease( - `sqlite:${this.storeIdentity}:${id}`, - () => Promise.resolve().then(() => this.acquireLiveRow(id, owner)), - ) - } - - private acquireLiveRow(id: SessionId, owner: SessionLiveOwner): () => Promise { - this.db.exec('BEGIN IMMEDIATE') - try { - const current = this.liveLeaseFor(id) - if (current !== undefined - && (current.pid !== owner.pid || current.nonce !== owner.nonce)) { - if (sessionLeaseOwnerIsLive(current, owner)) { - throw new Error(`session "${id}" is occupied by another live process`) - } - this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ?').run(id) - } - this.db.prepare(` - INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?) - ON CONFLICT(session_id) DO UPDATE SET pid = excluded.pid, nonce = excluded.nonce - `).run(id, owner.pid, owner.nonce) - this.db.exec('COMMIT') - } catch (error) { - this.db.exec('ROLLBACK') - throw error - } - return async () => { - await this.ready - this.db.prepare( - 'DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?', - ).run(id, owner.pid, owner.nonce) - } - } - - /** Report a non-stale SQLite lease and remove a crashed owner's row. */ - async inspectLive(id: SessionId, owner: SessionLiveOwner): Promise { - await this.ready - const current = this.liveLeaseFor(id) - if (current === undefined) return false - if ((current.pid === owner.pid && current.nonce === owner.nonce) - || sessionLeaseOwnerIsLive(current, owner)) return true - this.db.prepare('DELETE FROM live_session_leases WHERE session_id = ? AND pid = ? AND nonce = ?') - .run(id, current.pid, current.nonce) - return false - } - /** Close the database handle (awaited by the coordinator's dispose, post-drain). */ async close(): Promise { await this.ready @@ -342,11 +284,6 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined } - private liveLeaseFor(id: SessionId): { pid: number; nonce: string } | undefined { - return this.db.prepare('SELECT pid, nonce FROM live_session_leases WHERE session_id = ?') - .get(id) as { pid: number; nonce: string } | undefined - } - /** * Insert-or-replace a session's metadata row. The only caller is the first * materializing `appendBatch`, so writing the row IS the materialization (its diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index 6a5be76eb9..8b8dcd78e0 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 9 +export const SCHEMA_VERSION = 8 /** * A row of the `sessions` table — the out-of-log metadata ({@link SessionHeader}). @@ -68,7 +68,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' * rather than being migrated in place. * @param path - the SQLite database file to open (created when absent). * @param journalMode - validated journal pragma. - * @returns the open handle with pragmas applied and all tables ensured. + * @returns the open handle with pragmas applied and all three tables ensured. */ export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { const db = new DatabaseSync(path) @@ -128,13 +128,6 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM PRIMARY KEY (session_id, seq) ) STRICT `) - db.exec(` - CREATE TABLE IF NOT EXISTS live_session_leases ( - session_id TEXT PRIMARY KEY, - pid INTEGER NOT NULL, - nonce TEXT NOT NULL - ) STRICT - `) } /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index b670520a5c..3976e71549 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' @@ -7,16 +7,12 @@ import { dirname, join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' -import { sessionLiveOwner } from '@deepseek-ai/dsh-session-persistence' import { openDatabase, rowToEvent, scanRows, type EventRow } from '../src/schema.ts' import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts' const dirs: string[] = [] -afterEach(async () => { - vi.restoreAllMocks() - for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) -}) +afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) async function expectFlushError(promise: Promise, message: RegExp): Promise { try { @@ -446,7 +442,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(9) + expect(SCHEMA_VERSION).toBe(8) }) it('keeps the revision stable for an empty repair hook', async () => { @@ -462,47 +458,6 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { - it('claims, rejects, reclaims, inspects, and releases SQLite live leases', async () => { - const path = await freshDbPath() - const b = await backend(path) - await b.ctx.sessionPersistence.list() - const concrete = b.ctx.sessionPersistence as SessionPersistenceSqlite - const owner = sessionLiveOwner() - const occupiedPid = process.pid + 1 - const originalKill = process.kill.bind(process) - vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { - if (pid === occupiedPid) return true - return originalKill(pid, signal) - }) - const db = openDatabase(path, 'wal') - const insert = db.prepare('INSERT INTO live_session_leases (session_id, pid, nonce) VALUES (?, ?, ?)') - insert.run('occupied-lease', occupiedPid, 'another-owner') - insert.run('reused-pid', process.pid, 'prior-incarnation') - insert.run('stale-claim', 2_147_483_647, 'dead-owner') - insert.run('stale-inspect', 2_147_483_647, 'dead-owner') - insert.run('owned-inspect', owner.pid, owner.nonce) - db.close() - - await expect(concrete.acquireLive(SessionId('occupied-lease'), owner)) - .rejects.toThrow('occupied by another live process') - const reused = await concrete.acquireLive(SessionId('reused-pid'), owner) - const claim = await concrete.acquireLive(SessionId('stale-claim'), owner) - expect(await concrete.inspectLive(SessionId('owned-inspect'), owner)).toBe(true) - expect(await concrete.inspectLive(SessionId('stale-inspect'), owner)).toBe(false) - expect(await concrete.inspectLive(SessionId('missing-inspect'), owner)).toBe(false) - await claim() - await reused() - await b.dispose() - - const memory = new Context() - await memory.plugin(SessionStore) - await memory.plugin(SessionPersistenceSqlite, { path: ':memory:' }) - const memoryClaim = await memory.sessionPersistence.claimLive(SessionId('memory-live')) - expect(await memory.sessionPersistence.isLive(SessionId('memory-live'))).toBe(true) - await memoryClaim.release() - await memory.fiber.dispose() - }) - it('rejects and closes a current-schema database with an invalid store identity', async () => { const path = await freshDbPath() const db = openDatabase(path, 'wal') diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 0aec3a4bd5..25429bd720 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -15,10 +15,6 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `inspect(id): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; intended for read models and other observers that must never recover a log. | | `list(): Promise` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. | -| `claimLive(id): Promise` | Atomically claim live ownership. First-party backends reject another live process and reclaim a dead owner; release follows quiescence. | -| `isLive(id): Promise` | Report a current non-stale live lease, including one owned by this process. | - -The abstract base supplies a process-local fallback for lightweight third-party implementations. A backend that needs multi-process safety overrides both live-lease methods. ## Invariants every backend must honor @@ -35,7 +31,7 @@ Each `session/event` copies its event into the session controller and starts an Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it with the coordinator's stored header only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. A cold load reserves its id across backend reads and repair writes, so concurrent publication of a same-id live `Session` rejects and rolls back. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn. -When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state and the backend-owned live lease for that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, releases their leases, awaits per-id operations, and only then closes the storage handle. +When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. @@ -48,8 +44,6 @@ The `PersistenceBackend` hooks (the only seam between the coordinato | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | | `list()` | List all stored metadata. | -| `acquireLive?(id, owner)` | Atomically acquire a backend-owned cross-process lease and return its physical release. | -| `inspectLive?(id, owner)` | Report or reclaim a backend-owned lease without acquiring it. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path validates and clones the prefix without calling `commitRepair` or publishing write state. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). @@ -85,4 +79,3 @@ Persistence does not mutate live request prefixes. A resumed loop can reuse prov - **No deletion or retention surface** — pruning stored sessions is out-of-band backend maintenance. - **`list()` is unpaginated and unfiltered** — it returns every stored session's header; fine for local stores, unindexed at scale. - **Repair-time synthetic closers are the only crash story** — a backend must synthesize `tool/result`/`step/end`/`turn/end` closers on load; there is no partial-turn resume that continues an interrupted turn instead of closing it. -- **Foreign PID reuse is fail-closed** — a claimant with the reused PID detects its different nonce and reclaims safely, but another process cannot observe that foreign process's private nonce and treats the PID as live until it exits or an operator verifies and removes the stale lease. diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index 8ea1790b3e..fb46aa4877 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -8,8 +8,6 @@ import { Context } from 'cordis' import { interruptedTurnClosers, SESSION_FORMAT_VERSION, snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' -import { sessionLiveOwner } from './lease.ts' -import type { SessionLiveLease, SessionLiveOwner } from './lease.ts' /** * A stored session's header, valid contiguous event prefix, and optional opaque @@ -65,12 +63,6 @@ export interface PersistenceBackend { /** List all stored (materialized) sessions' metadata. */ list(): Promise - /** Optionally acquire a backend-owned cross-process live-session lease. */ - acquireLive?(id: SessionId, owner: SessionLiveOwner): Promise<() => Promise> - - /** Optionally inspect and reclaim a backend-owned live-session lease. */ - inspectLive?(id: SessionId, owner: SessionLiveOwner): Promise - /** * Optional lifecycle teardown (e.g. close a database handle). Awaited by the * coordinator's dispose effect AFTER the quiescence drain. A stateless file @@ -104,7 +96,6 @@ interface LiveSessionState { pending: SessionEvent[] init: Promise flush: Promise | undefined - lease?: SessionLiveLease } /** Collect the rejection reasons from a set of promises (none-throwing). */ @@ -170,12 +161,6 @@ export class PersistenceCoordinator { * same id, so writes for one session never interleave. Keyed by session id. */ private chains = new Map>() - /** One backend lease with process-local reference counting per session id. */ - private liveClaims = new Map Promise - }>() - private readonly liveOwner = sessionLiveOwner() constructor(private ctx: Context, private backend: PersistenceBackend) { this.installWritePath() @@ -288,61 +273,6 @@ export class PersistenceCoordinator { return this.serialize(id, () => this.inspectCore(id)) } - /** - * Acquire one process-local reference to the backend's cross-process lease. - * @param id - session identity about to become live. - * @returns one idempotent release capability. - */ - async claimLive(id: SessionId): Promise { - const acquireLive = this.backend.acquireLive?.bind(this.backend) - if (acquireLive === undefined) return { release: () => Promise.resolve() } - await this.serialize(id, async () => { - const existing = this.liveClaims.get(id) - if (existing !== undefined) { - existing.refs += 1 - return - } - const releaseBackend = await acquireLive(id, this.liveOwner) - this.liveClaims.set(id, { refs: 1, releaseBackend }) - }) - let releaseTask: Promise | undefined - return { - release: () => { - if (releaseTask !== undefined) return releaseTask - const task = this.serialize(id, async () => { - const claim = this.liveClaims.get(id) - /* v8 ignore next -- this capability is returned only after its claim enters the serialized map */ - if (claim === undefined) return - claim.refs -= 1 - if (claim.refs > 0) return - try { - await claim.releaseBackend() - } catch (error) { - claim.refs += 1 - throw error - } - this.liveClaims.delete(id) - }) - const wrapped = task.catch((error: unknown) => { - releaseTask = undefined - throw error - }) - releaseTask = wrapped - return wrapped - }, - } - } - - /** - * Check the backend's current cross-process lease state. - * @param id - session identity to inspect. - * @returns whether this or another live process owns the session. - */ - isLive(id: SessionId): Promise { - if (this.liveClaims.has(id)) return Promise.resolve(true) - return this.backend.inspectLive?.(id, this.liveOwner) ?? Promise.resolve(false) - } - private async inspectCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { const stored = await this.backend.loadStored(id) if (stored === undefined) throw new Error(`session "${id}" not found`) @@ -452,9 +382,6 @@ export class PersistenceCoordinator { let disposeError: unknown try { const errors = await settledErrors([...this.live.keys()].map(session => this.flush(session))) - errors.push(...await settledErrors( - [...this.live.values()].flatMap(live => live.lease === undefined ? [] : [live.lease.release()]), - )) while (this.chains.size > 0) await Promise.allSettled([...this.chains.values()]) if (errors.length > 0) { throw new AggregateError(errors, `${this.backend.name} dispose failed`) @@ -514,8 +441,6 @@ export class PersistenceCoordinator { private async retireCore(session: Session): Promise { await this.flush(session) const id = session.header.id - const live = this.live.get(session) - await live?.lease?.release() await this.serialize(id, () => { this.live.delete(session) if (this.states.get(id)?.owner === session) this.states.delete(id) @@ -529,16 +454,7 @@ export class PersistenceCoordinator { const seed = session.events.map(e => structuredClone(e)) const live: LiveSessionState = { pending: [], init: Promise.resolve(), flush: undefined } this.live.set(session, live) - live.init = this.claimLive(session.id).then(async (lease) => { - live.lease = lease - try { - await this.serialize(session.header.id, () => this.onCreated(session, seed)) - } catch (error) { - delete live.lease - await lease.release() - throw error - } - }) + live.init = this.serialize(session.header.id, () => this.onCreated(session, seed)) live.init.catch(() => { /* observed by flush/dispose through the controller */ }) return live } diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index d6bbee0616..c785c9354c 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -8,18 +8,10 @@ import { Context, Service } from 'cordis' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' -import type { SessionLiveLease } from './lease.ts' // Re-export the metadata vocabulary so consumers import it from the seam. export type { SessionHeader } from '@deepseek-ai/dsh-session' export { SessionPersistenceRevision } from './revision.ts' -export { - sessionLeaseOwnerIsLive, - sessionLeaseProcessIsLive, - sessionLiveOwner, - shareSessionLiveLease, -} from './lease.ts' -export type { SessionLiveLease, SessionLiveOwner } from './lease.ts' /** Lightweight immutable source identity returned without loading a full log. */ export interface SessionPersistenceSnapshot { @@ -58,8 +50,6 @@ export interface SessionLocation { * rewriting committed events. */ export abstract class SessionPersistence extends Service { - private readonly localLiveClaims = new Map() - constructor(ctx: Context) { super(ctx, 'sessionPersistence') } @@ -133,39 +123,6 @@ export abstract class SessionPersistence extends Service { * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(): Promise - - /** - * Atomically acquire this process's live ownership of a session id. - * Reentrant claims share one backend lease. First-party backends override - * this process-local fallback to reject another live process and reclaim a - * dead owner. - * @param id - session identity that is about to become live. - * @returns a single-release reference owned by the caller. - */ - claimLive(id: SessionId): Promise { - this.localLiveClaims.set(id, (this.localLiveClaims.get(id) ?? 0) + 1) - let released = false - return Promise.resolve({ - release: () => { - if (released) return Promise.resolve() - released = true - const refs = this.localLiveClaims.get(id) as number - if (refs <= 1) this.localLiveClaims.delete(id) - else this.localLiveClaims.set(id, refs - 1) - return Promise.resolve() - }, - }) - } - - /** - * Check whether any process currently owns a live lease for this session. - * The base implementation reports only claims on this service instance. - * @param id - persisted or prospective session identity. - * @returns true while a non-stale lease exists, including this process's lease. - */ - isLive(id: SessionId): Promise { - return Promise.resolve(this.localLiveClaims.has(id)) - } } export default SessionPersistence diff --git a/packages/session-persistence/session-persistence/src/lease.ts b/packages/session-persistence/session-persistence/src/lease.ts deleted file mode 100644 index 148d8e117f..0000000000 --- a/packages/session-persistence/session-persistence/src/lease.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** Process-backed identity helpers for cross-process live-session leases. */ - -import { randomUUID } from 'node:crypto' - -const LIVE_OWNER_ENV = 'DSH_SESSION_LIVE_OWNER' - -/** Process identity stored in backend-owned cross-process live-session leases. */ -export interface SessionLiveOwner { - /** Operating-system process id; retained across an `execve` handoff. */ - readonly pid: number - /** Exec-stable process-start nonce used when the observer has the same PID. */ - readonly nonce: string -} - -/** Idempotent capability releasing one acquired live-session lease reference. */ -export interface SessionLiveLease { - /** Release this caller's lease reference after its live session reaches quiescence. */ - release(): Promise -} - -/** - * Stable owner inherited only by an exec-replaced process, not inferred from a session id. - * @returns this process's PID and exec-stable nonce. - */ -export function sessionLiveOwner(): SessionLiveOwner { - const nonce = process.env[LIVE_OWNER_ENV] ?? randomUUID() - process.env[LIVE_OWNER_ENV] = nonce - return { pid: process.pid, nonce } -} - -/** - * Whether a lease pid still names a process; permission denial counts as live. - * @param pid - positive operating-system process id from a lease record. - * @returns true unless the operating system reports that the process is absent. - */ -export function sessionLeaseProcessIsLive(pid: number): boolean { - try { - process.kill(pid, 0) - return true - } catch (error) { - return (error as NodeJS.ErrnoException).code !== 'ESRCH' - } -} - -/** - * Whether a recorded owner still names this process incarnation or another live PID. - * A same-PID nonce mismatch proves reuse and is stale; an unrelated live PID is - * fail-closed because its private nonce is not observable across processes. - * @param recorded - owner stored in the backend lease. - * @param observer - identity of the process inspecting or claiming the lease. - * @returns whether the recorded owner must still be treated as live. - */ -export function sessionLeaseOwnerIsLive( - recorded: SessionLiveOwner, - observer: SessionLiveOwner, -): boolean { - if (recorded.pid === observer.pid) return recorded.nonce === observer.nonce - return sessionLeaseProcessIsLive(recorded.pid) -} - -interface SharedLeaseEntry { - refs: number - readonly acquired: Promise<() => Promise> - finalizing?: Promise -} - -const sharedLeases = new Map() - -/** - * Reference-count one physical lease across backend instances in this process. - * @param key - backend-kind plus canonical storage location and session id. - * @param acquire - single physical acquisition performed for the first reference. - * @returns an idempotent release for this caller's reference. - */ -export async function shareSessionLiveLease( - key: string, - acquire: () => Promise<() => Promise>, -): Promise<() => Promise> { - for (;;) { - let entry = sharedLeases.get(key) - if (entry?.finalizing !== undefined) { - await entry.finalizing - continue - } - if (entry === undefined) { - entry = { refs: 0, acquired: acquire() } - sharedLeases.set(key, entry) - void entry.acquired.catch(() => { - /* v8 ignore next -- no public operation can replace a still-acquiring module-private entry */ - if (sharedLeases.get(key) === entry) sharedLeases.delete(key) - }) - } - entry.refs += 1 - try { - await entry.acquired - } catch (error) { - entry.refs -= 1 - throw error - } - let releaseTask: Promise | undefined - return () => { - if (releaseTask !== undefined) return releaseTask - const task = (async () => { - entry.refs -= 1 - if (entry.refs > 0 || sharedLeases.get(key) !== entry) return - const release = await entry.acquired - await release() - /* v8 ignore next -- claims wait for finalization before they can replace this exact entry */ - if (sharedLeases.get(key) === entry) sharedLeases.delete(key) - })() - const wrapped = task.catch((error: unknown) => { - entry.refs += 1 - /* v8 ignore next -- this closure is the sole writer of its release state until settlement */ - if (entry.finalizing === wrapped) delete entry.finalizing - releaseTask = undefined - throw error - }) - if (entry.refs === 0 && sharedLeases.get(key) === entry) entry.finalizing = wrapped - releaseTask = wrapped - return wrapped - } - } -} diff --git a/packages/session-persistence/session-persistence/tests/lease.spec.ts b/packages/session-persistence/session-persistence/tests/lease.spec.ts deleted file mode 100644 index 8c38aac874..0000000000 --- a/packages/session-persistence/session-persistence/tests/lease.spec.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { randomUUID } from 'node:crypto' -import { - sessionLeaseOwnerIsLive, - sessionLeaseProcessIsLive, - sessionLiveOwner, - shareSessionLiveLease, -} from '../src/lease.ts' - -const originalOwner = process.env.DSH_SESSION_LIVE_OWNER - -afterEach(() => { - vi.restoreAllMocks() - if (originalOwner === undefined) delete process.env.DSH_SESSION_LIVE_OWNER - else process.env.DSH_SESSION_LIVE_OWNER = originalOwner -}) - -describe('process live-session lease helpers', () => { - it('creates one exec-stable owner identity and classifies process liveness', () => { - delete process.env.DSH_SESSION_LIVE_OWNER - const first = sessionLiveOwner() - expect(first.pid).toBe(process.pid) - expect(typeof first.nonce).toBe('string') - expect(sessionLiveOwner()).toEqual(first) - expect(sessionLeaseOwnerIsLive(first, first)).toBe(true) - expect(sessionLeaseOwnerIsLive({ ...first, nonce: 'reused-pid' }, first)).toBe(false) - expect(sessionLeaseProcessIsLive(process.pid)).toBe(true) - - const missing = Object.assign(new Error('missing'), { code: 'ESRCH' }) - vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing }) - expect(sessionLeaseOwnerIsLive({ pid: 999_999, nonce: 'gone' }, first)).toBe(false) - vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw missing }) - expect(sessionLeaseProcessIsLive(999_999)).toBe(false) - const denied = Object.assign(new Error('denied'), { code: 'EPERM' }) - vi.spyOn(process, 'kill').mockImplementationOnce(() => { throw denied }) - expect(sessionLeaseProcessIsLive(999_998)).toBe(true) - }) - - it('shares one physical lease until every process-local reference releases', async () => { - const releasePhysical = vi.fn<() => Promise>(() => Promise.resolve()) - const acquire = vi.fn<() => Promise<() => Promise>>(() => Promise.resolve(releasePhysical)) - const key = `shared-${randomUUID()}` - const first = await shareSessionLiveLease(key, acquire) - const second = await shareSessionLiveLease(key, acquire) - expect(acquire).toHaveBeenCalledTimes(1) - await first() - expect(releasePhysical).not.toHaveBeenCalled() - await second() - await second() - expect(releasePhysical).toHaveBeenCalledTimes(1) - }) - - it('removes failed acquisitions and retries a failed physical release', async () => { - const key = `retry-${randomUUID()}` - await expect(shareSessionLiveLease(key, () => Promise.reject(new Error('claim failed')))) - .rejects.toThrow('claim failed') - - let releases = 0 - const release = await shareSessionLiveLease(key, () => Promise.resolve(async () => { - releases += 1 - if (releases === 1) throw new Error('release failed') - })) - await expect(release()).rejects.toThrow('release failed') - await expect(release()).resolves.toBeUndefined() - expect(releases).toBe(2) - }) - - it('waits for a final physical release before reacquiring the same key', async () => { - const key = `finalizing-${randomUUID()}` - const releaseGate = Promise.withResolvers() - const firstPhysicalRelease = vi.fn(() => releaseGate.promise) - const secondPhysicalRelease = vi.fn(() => Promise.resolve()) - const releases: Array<() => Promise> = [firstPhysicalRelease, secondPhysicalRelease] - let acquisitions = 0 - const acquire = vi.fn<() => Promise<() => Promise>>((): Promise<() => Promise> => { - const release = releases[acquisitions++] - if (release === undefined) throw new Error('unexpected physical acquisition') - return Promise.resolve(release) - }) - const first = await shareSessionLiveLease(key, acquire) - const finalizing = first() - const reacquiring = shareSessionLiveLease(key, acquire) - await Promise.resolve() - expect(acquire).toHaveBeenCalledTimes(1) - releaseGate.resolve(undefined) - await finalizing - const second = await reacquiring - expect(acquire).toHaveBeenCalledTimes(2) - await second() - expect(secondPhysicalRelease).toHaveBeenCalledTimes(1) - }) -}) diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 36192c37d7..6b31d0843b 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -4,7 +4,7 @@ import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, - type PersistenceBackend, type SessionLiveOwner, type SessionPersistenceSnapshot, type StoredPrefix, + type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix, } from '../src/index.ts' import { runPersistenceContract, meta, oneTurnLog } from './contract.ts' import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts' @@ -348,46 +348,6 @@ describe('PersistenceCoordinator stored identity', () => { }) }) -describe('PersistenceCoordinator live leases', () => { - it('degrades without backend hooks and retries a failed final release', async () => { - const fallbackCtx = new Context() - await fallbackCtx.plugin(SessionStore) - const fallback = new PersistenceCoordinator(fallbackCtx, new ControlledBackend()) - const fallbackClaim = await fallback.claimLive(SessionId('fallback-live')) - expect(await fallback.isLive(SessionId('fallback-live'))).toBe(false) - await fallbackClaim.release() - await fallbackCtx.fiber.dispose() - - class LeaseBackend extends ControlledBackend { - releaseAttempts = 0 - async acquireLive(_id: SessionId, _owner: SessionLiveOwner): Promise<() => Promise> { - return async () => { - this.releaseAttempts += 1 - if (this.releaseAttempts === 1) throw new Error('lease release failed') - } - } - inspectLive(): Promise { - return Promise.resolve(true) - } - } - - const ctx = new Context() - await ctx.plugin(SessionStore) - const backend = new LeaseBackend() - const coordinator = new PersistenceCoordinator(ctx, backend) - const first = await coordinator.claimLive(SessionId('leased')) - const second = await coordinator.claimLive(SessionId('leased')) - expect(await coordinator.isLive(SessionId('leased'))).toBe(true) - await first.release() - await expect(second.release()).rejects.toThrow('lease release failed') - await expect(second.release()).resolves.toBeUndefined() - await expect(second.release()).resolves.toBeUndefined() - expect(backend.releaseAttempts).toBe(2) - expect(await coordinator.isLive(SessionId('leased'))).toBe(true) - await ctx.fiber.dispose() - }) -}) - describe('PersistenceCoordinator retirement', () => { it('a retiring unmaterialized owner without buffered events releases its id', async () => { const ctx = new Context() @@ -835,20 +795,4 @@ describe('SessionPersistence service registration', () => { await fiber.dispose() } }) - - it('provides a reference-counted process-local lease fallback', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(MemoryPersistence) - const id = SessionId('local-live') - const first = await ctx.sessionPersistence.claimLive(id) - const second = await ctx.sessionPersistence.claimLive(id) - expect(await ctx.sessionPersistence.isLive(id)).toBe(true) - await first.release() - await first.release() - expect(await ctx.sessionPersistence.isLive(id)).toBe(true) - await second.release() - expect(await ctx.sessionPersistence.isLive(id)).toBe(false) - await ctx.fiber.dispose() - }) }) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index db13d8733e..8dc10f20f8 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑ `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. -`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, another live owner's session, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks, requires the current agent to be idle, and claims the target live lease before flushing the current session; a lost claim race or later recoverable failure leaves the current TUI running and releases any acquired reservation. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and atomically replaces its process while retaining the reservation, so two runtimes never own the terminal together. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. +`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. `resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text. @@ -156,6 +156,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work +- **Resume has no cross-process session lock** — the selector rejects sessions known to be live in its own runtime, but another process can resume the same persisted id before or during handoff. Deployments that can run concurrent hosts must coordinate ownership outside the TUI. - **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`. - **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering. - **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 90297fc478..372a0c9497 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -79,7 +79,7 @@ import type { } from '@deepseek-ai/dsh-session-query' // Type import also declaration-merges the optional `sessionPersistence` // service onto `Context` so `ctx.get('sessionPersistence')` is typed. -import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence' +import type {} from '@deepseek-ai/dsh-session-persistence' import type { SkillDefinition, SkillResourceBase, SkillService } from '@deepseek-ai/dsh-skill' import type { FileDiff, @@ -349,7 +349,7 @@ export interface TuiRuntime { formatCwd?: (cwd: string | undefined) => string /** Monotonic-enough wall clock for elapsed status rendering. Defaults to `Date.now`. */ now?(): number - /** Host-owned safe process handoff; absent leaves `resumeCommand` as the fallback. */ + /** Host-owned process handoff; absent leaves `resumeCommand` as the fallback. */ handoffResume?: TuiResumeHost['handoff'] } @@ -1283,7 +1283,6 @@ interface ResumeRoute { interface ResumeCandidate { record: SessionRecord - occupied: boolean title: string lastActivityAt: number lastTurn: string @@ -1324,7 +1323,6 @@ function summarizeResumeCandidate( snapshot: SessionLogSnapshot, currentId: SessionId, cwd: string | undefined, - occupied: boolean, availableProviders: ReadonlySet, ): ResumeCandidate { const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' @@ -1332,14 +1330,13 @@ function summarizeResumeCandidate( const foldedGoal = foldGoal(snapshot.events).goal let disabledReason: string | undefined if (record.header.id === currentId) disabledReason = 'current session' - else if (record.live || occupied) disabledReason = 'occupied by another live agent' + else if (record.live) disabledReason = 'session is already live in this runtime' else if (record.header.cwd !== cwd) disabledReason = 'different workspace' else if (route !== undefined && !availableProviders.has(route.provider)) { disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` } return { record, - occupied, title, lastActivityAt: snapshot.events.at(-1)?.time ?? snapshot.session.createdAt, lastTurn: resumeTurnLabel(snapshot), @@ -1422,7 +1419,7 @@ class ResumeDialog implements Component, Focusable { const selected = index === this.selectedIndex const status = [ candidate.disabledReason === 'current session' ? 'current' : undefined, - candidate.record.live || candidate.occupied ? 'live' : undefined, + candidate.record.live ? 'live' : undefined, candidate.record.persisted ? 'persisted' : undefined, ].filter((value): value is string => value !== undefined).join(' · ') const lead = `${selected ? '›' : ' '} ${displayText(candidate.title)}` @@ -1841,8 +1838,6 @@ export function createTuiChat( let modelOverlay: TuiOverlaySession | undefined let resumeOverlay: TuiOverlaySession | undefined let resumeInFlight = false - let resumeReservation: SessionLiveLease | undefined - let resumeReservationCommitted = false let resumeScan = 0 let tuiServiceFiber: Fiber | undefined const target: AgentLlmTargetRef = { current: initialTarget(agent), assembled: undefined } @@ -1855,12 +1850,6 @@ export function createTuiChat( const now = (): number => runtime.now?.() ?? Date.now() const agentStatus = (): AgentStatus => agent.status const isDisposed = (): boolean => disposed - const releaseResumeReservation = async (): Promise => { - const reservation = resumeReservation - if (reservation === undefined) return - await reservation.release() - resumeReservation = undefined - } // A configured subtitle renders as a banner line; when absent, the banner has // no subtitle. The banner itself sweeps in on start (see startBannerReveal). @@ -2444,8 +2433,6 @@ export function createTuiChat( shuttingDown ??= (async () => { disposed = true overlayManager.beginShutdown() - /* v8 ignore else -- the committed branch is the non-returning exec handoff covered by the keyless PTY test */ - if (!resumeReservationCommitted) await releaseResumeReservation() contextResolution = undefined clearStatus() for (const controller of commandControllers) controller.abort(new Error('TUI disposed')) @@ -2825,9 +2812,6 @@ export function createTuiChat( providers: ReadonlySet, ): Promise => { try { - const occupied = record.live || (record.persisted && persistence !== undefined - ? await persistence.isLive(record.header.id) - : false) let snapshot: SessionLogSnapshot const live = ctx.sessions.get(record.header.id) if (live !== undefined) { @@ -2845,13 +2829,11 @@ export function createTuiChat( snapshot, agent.session.id, agent.session.header.cwd, - occupied, providers, ) } catch (error: unknown) { return { record, - occupied: record.live, title: 'Unreadable session', lastActivityAt: record.header.createdAt, lastTurn: 'log unavailable', @@ -2895,14 +2877,8 @@ export function createTuiChat( : `This host cannot hand off in place. Exit and run: ${fallback}`, 'warning') return } - if (persistence === undefined) { - throw new Error('Resume is unavailable: session persistence is not mounted.') - } - resumeReservation = await persistence.claimLive(checked.record.header.id) - if (disposed) { - await releaseResumeReservation() - return - } + /* v8 ignore next -- shutdown during preflight invalidates an awaited service read or reaches this guard */ + if (disposed) return await ctx.sessions.flush(agent.session) // Disposal can run while the flush promise is pending; TypeScript does not model that reentry. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition @@ -2916,29 +2892,18 @@ export function createTuiChat( if (disposed) return ui.stop() terminalReleased = true - resumeReservationCommitted = true await hostHandoff(checked.record.header.id) throw new Error('resume host returned without replacing the process') } catch (error: unknown) { - /* v8 ignore next -- a committed host disposes this TUI and never returns; recoverable rejection keeps it live */ if (!disposed) { - resumeReservationCommitted = false - let reported = error - try { - await releaseResumeReservation() - } catch (releaseError: unknown) { - reported = new Error( - `${errorChain(error)}; target reservation release failed: ${errorChain(releaseError)}`, - ) - } if (terminalReleased) { ui.start() ui.setFocus(editor) - appendNotice(`Resume handoff failed: ${errorChain(reported)}`, 'error') + appendNotice(`Resume handoff failed: ${errorChain(error)}`, 'error') } else { await overlay.close() resumeOverlay = undefined - appendNotice(`Resume failed: ${errorChain(reported)}`, 'error') + appendNotice(`Resume failed: ${errorChain(error)}`, 'error') } } } finally { diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 22692026e5..97d0947536 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -10,7 +10,6 @@ import AgentRegistry, { import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session' -import type { SessionLiveLease } from '@deepseek-ai/dsh-session-persistence' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -53,8 +52,6 @@ export interface TuiHarnessOptions { sessionPersistence?: { list(): Promise load?(id: ReturnType): Promise<{ meta: SessionHeader; events: Session['events'] }> - isLive?(id: ReturnType): Promise - claimLive?(id: ReturnType): Promise } handoffResume?: TuiRuntime['handoffResume'] /** Set false to exercise the optional session-query degradation path. */ @@ -140,12 +137,6 @@ export async function createTuiTestHarness) => Promise.reject(new Error(`session "${id}" not found`)) : (id: ReturnType) => persistence.load!(id), - claimLive: persistence.claimLive === undefined - ? () => Promise.resolve({ release: () => Promise.resolve() }) - : (id: ReturnType) => persistence.claimLive!(id), - isLive: persistence.isLive === undefined - ? () => Promise.resolve(false) - : (id: ReturnType) => persistence.isLive!(id), } as never) } if (options.mountSessionQuery !== false && ctx.get('sessionQuery') === undefined) { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 635446b041..0ec1d4a6e2 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -396,7 +396,7 @@ describe('resume command and /resume', () => { await dispose(result) }) - it('keeps persisted query records readable when live-lease inspection is unavailable', async () => { + it('keeps persisted query records readable without a persistence service', async () => { const target = header('query-only-persisted', 10, '/workspace') const result = await setup({ cwd: '/workspace', @@ -503,21 +503,19 @@ describe('resume command and /resume', () => { expect(result.terminal.stopped).toBeGreaterThan(0) }) - it('preflights route availability and occupied or corrupt sessions without losing the current TUI', async () => { + it('preflights route availability and corrupt sessions without losing the current TUI', async () => { const missing = header('missing-route', 10, '/workspace') - const occupied = header('occupied', 20, '/workspace') const corrupt = header('corrupt', 30, '/workspace') const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME }, sessionPersistence: { - list: async () => [missing, occupied, corrupt], - isLive: async id => id === occupied.id, + list: async () => [missing, corrupt], load: async (id) => { if (id === corrupt.id) throw new Error('checksum mismatch') return { - meta: id === missing.id ? missing : occupied, - events: resumeEvents(id === missing.id ? 'Missing adapter' : 'Busy session', id === missing.id ? 'absent-provider' : 'deepseek'), + meta: missing, + events: resumeEvents('Missing adapter', 'absent-provider'), } }, }, @@ -527,7 +525,6 @@ describe('resume command and /resume', () => { await tick(); await tick() expect(result.terminal.output).toContain('Missing adapter') expect(result.terminal.output).toContain('absent-provider/model-1') - expect(result.terminal.output).toContain('Busy session') expect(result.terminal.output).toContain('Unreadable session') result.terminal.send('Missing adapter') result.terminal.send('\r') @@ -537,6 +534,38 @@ describe('resume command and /resume', () => { await dispose(result) }) + it('keeps a session already live in this runtime visible but disabled', async () => { + const target = header('live-target', 10, '/workspace') + const handoff = vi.fn>() + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ + header: target, + live: true, + persisted: true, + }]), + readSession: () => Promise.resolve({ + session: target, + events: resumeEvents('Live target'), + }), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Live target') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('session is already live in this runtime') + expect(handoff).not.toHaveBeenCalled() + await dispose(result) + }) + it('falls back to assistant provenance and header creation time for sparse logs', async () => { const assistantOnly = header('assistant-route', 20, '/workspace') const empty = header('empty-log', 10, '/workspace') @@ -562,8 +591,6 @@ describe('resume command and /resume', () => { it('flushes, releases the terminal, and invokes one host handoff for the same SessionId', async () => { const target = header('target-session', 10, '/workspace') - const releaseReservation = vi.fn(() => Promise.resolve()) - const claimLive = vi.fn(async () => ({ release: releaseReservation })) const handoff = vi.fn>(() => Promise.reject(new Error('test host retained process'))) const result = await setup({ cwd: '/workspace', @@ -571,7 +598,6 @@ describe('resume command and /resume', () => { sessionPersistence: { list: async () => [target], load: async () => ({ meta: target, events: resumeEvents('Target session') }), - claimLive, }, }) result.terminal.send('/resume') @@ -582,8 +608,6 @@ describe('resume command and /resume', () => { await tick(); await tick() expect(handoff).toHaveBeenCalledTimes(1) expect(handoff).toHaveBeenCalledWith(target.id) - expect(claimLive).toHaveBeenCalledWith(target.id) - expect(releaseReservation).toHaveBeenCalledTimes(1) expect(result.terminal.stopped).toBeGreaterThan(0) expect(result.terminal.output).toContain('Resume handoff failed: test host retained process') await dispose(result) @@ -635,39 +659,46 @@ describe('resume command and /resume', () => { await dispose(result) }) - it('keeps the current TUI when the target reservation loses the preflight race', async () => { - const target = header('reservation-race', 10, '/workspace') + it('does not flush or hand off when disposal begins during selected-session preflight', async () => { + const target = header('dispose-during-preflight', 10, '/workspace') + const secondListing = Promise.withResolvers() const handoff = vi.fn>() const flush = vi.fn() + let listings = 0 + const record: SessionRecord = { header: target, live: false, persisted: true } const result = await setup({ cwd: '/workspace', handoffResume: handoff, async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) ctx.on('session/flush', flush) - }, - sessionPersistence: { - list: async () => [target], - load: async () => ({ meta: target, events: resumeEvents('Reservation race') }), - claimLive: () => Promise.reject(new Error('occupied after preflight')), + ctx.provide('sessionQuery', { + listSessions: () => ++listings === 1 ? Promise.resolve([record]) : secondListing.promise, + readSession: () => Promise.resolve({ + session: target, + events: resumeEvents('Dispose during preflight'), + }), + } as never) }, }) result.terminal.send('/resume') result.terminal.send('\r') - await tick(); await tick() - result.terminal.send('Reservation race') + await tick() + result.terminal.send('Dispose during preflight') result.terminal.send('\r') - await tick(); await tick() - expect(result.terminal.output).toContain('Resume failed: occupied after preflight') + await vi.waitFor(() => { expect(listings).toBe(2) }) + await dispose(result) + secondListing.resolve([record]) + await tick() expect(flush).not.toHaveBeenCalled() expect(handoff).not.toHaveBeenCalled() - expect(result.terminal.stopped).toBe(0) - await dispose(result) }) - it('refuses host handoff when a query backend has no persistence lease service', async () => { + it('hands off a validated session exposed by a query backend without a persistence service', async () => { const target = header('query-without-persistence', 10, '/workspace') - const handoff = vi.fn>() + const handoff = vi.fn>( + () => Promise.reject(new Error('test host retained process')), + ) const result = await setup({ cwd: '/workspace', handoffResume: handoff, @@ -692,42 +723,14 @@ describe('resume command and /resume', () => { result.terminal.send('Query without persistence') result.terminal.send('\r') await tick(); await tick() - expect(result.terminal.output).toContain('session persistence is not mounted') - expect(handoff).not.toHaveBeenCalled() + expect(handoff).toHaveBeenCalledWith(target.id) + expect(result.terminal.output).toContain('Resume handoff failed: test host retained process') await dispose(result) }) - it('releases a reservation that resolves after TUI disposal', async () => { - const target = header('late-reservation', 10, '/workspace') - const claiming = Promise.withResolvers<{ release(): Promise }>() - const release = vi.fn(() => Promise.resolve()) - const handoff = vi.fn>() - const result = await setup({ - cwd: '/workspace', - handoffResume: handoff, - sessionPersistence: { - list: async () => [target], - load: async () => ({ meta: target, events: resumeEvents('Late reservation') }), - claimLive: () => claiming.promise, - }, - }) - result.terminal.send('/resume') - result.terminal.send('\r') - await tick(); await tick() - result.terminal.send('Late reservation') - result.terminal.send('\r') - await tick() - await dispose(result) - claiming.resolve({ release }) - await tick() - expect(release).toHaveBeenCalledTimes(1) - expect(handoff).not.toHaveBeenCalled() - }) - it('does not hand off after disposal begins during the current-session flush', async () => { const target = header('dispose-during-flush', 10, '/workspace') const flushing = Promise.withResolvers() - const release = vi.fn(() => Promise.resolve()) const handoff = vi.fn>() const result = await setup({ cwd: '/workspace', @@ -739,7 +742,6 @@ describe('resume command and /resume', () => { sessionPersistence: { list: async () => [target], load: async () => ({ meta: target, events: resumeEvents('Dispose during flush') }), - claimLive: async () => ({ release }), }, }) result.terminal.send('/resume') @@ -752,14 +754,12 @@ describe('resume command and /resume', () => { await tick() flushing.resolve(undefined) await disposing - expect(release).toHaveBeenCalledTimes(1) expect(handoff).not.toHaveBeenCalled() }) it('does not hand off after disposal begins while terminal input drains', async () => { const target = header('dispose-during-drain', 10, '/workspace') const draining = Promise.withResolvers() - const release = vi.fn(() => Promise.resolve()) const handoff = vi.fn>() const result = await setup({ cwd: '/workspace', @@ -767,7 +767,6 @@ describe('resume command and /resume', () => { sessionPersistence: { list: async () => [target], load: async () => ({ meta: target, events: resumeEvents('Dispose during drain') }), - claimLive: async () => ({ release }), }, }) result.terminal.drainInput.mockImplementationOnce(() => draining.promise) @@ -780,36 +779,33 @@ describe('resume command and /resume', () => { await dispose(result) draining.resolve(undefined) await tick() - expect(release).toHaveBeenCalledTimes(1) expect(handoff).not.toHaveBeenCalled() }) - it('reports a target reservation release failure after a recoverable host rejection', async () => { - const target = header('release-failure', 10, '/workspace') - let releases = 0 + it('does not restart the terminal when a pending host rejects during disposal', async () => { + const target = header('host-rejects-during-disposal', 10, '/workspace') + const host = Promise.withResolvers() + const handoff = vi.fn>(() => host.promise) const result = await setup({ cwd: '/workspace', - handoffResume: () => Promise.reject(new Error('host rejected')), + handoffResume: handoff, sessionPersistence: { list: async () => [target], - load: async () => ({ meta: target, events: resumeEvents('Release failure') }), - claimLive: async () => ({ - release: () => ++releases === 1 - ? Promise.reject(new Error('lock unavailable')) - : Promise.resolve(), - }), + load: async () => ({ meta: target, events: resumeEvents('Host disposal') }), }, }) result.terminal.send('/resume') result.terminal.send('\r') await tick(); await tick() - result.terminal.send('Release failure') + result.terminal.send('Host disposal') result.terminal.send('\r') - await tick(); await tick() - expect(result.terminal.output).toContain('target reservation release failed') - expect(result.terminal.output).toContain('release failed: lock') + await vi.waitFor(() => { expect(handoff).toHaveBeenCalled() }) + const startsBeforeDispose = result.terminal.started await dispose(result) - expect(releases).toBe(2) + host.reject(new Error('host rejected after disposal')) + await tick() + expect(result.terminal.started).toBe(startsBeforeDispose) + expect(result.terminal.output).not.toContain('host rejected after disposal') }) it('rejects a candidate whose cwd changes between listing and preflight', async () => { diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 6be2d7ed83..d87fba3d08 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -95,7 +95,6 @@ export const LINK_MAP: Record = { CreateSessionOptions: 'persistence.md', SessionHeader: 'persistence.md', SessionLocation: 'persistence.md', - SessionLiveLease: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1472499819..db5fc0b85d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -379,11 +379,6 @@ "symbol": "SessionLocation", "source": "packages/session-persistence/session-persistence/src/index.ts" }, - { - "doc": "docs/core-data-structures/persistence.md", - "symbol": "SessionLiveLease", - "source": "packages/session-persistence/session-persistence/src/lease.ts" - }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", From d3b00bbdff2d16727c651632d2b0c5afb67983c8 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 16:13:13 +0800 Subject: [PATCH 12/15] test(tui): await fresh model selector frames --- packages/ui/tui/tests/tui.spec.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 0ec1d4a6e2..c6426d4c67 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -2249,22 +2249,27 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(result.terminal.output).toContain('advertised by multiple providers') expect(result.terminal.output).toContain('already alpha/a1') + const firstSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') result.terminal.send('/model') result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Select model') + await vi.waitFor(() => { + expect(result.terminal.output.slice(firstSelectorOutput)).toContain('Select model') + }) result.terminal.send('\x1b') await tick() result.agent.status = 'running' + const runningSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') - await tick() - expect(result.terminal.output).toContain('Select model') - expect(result.terminal.output).toContain('alpha/a1') - expect(result.terminal.output).toContain('Alpha One — Fast — current') + await vi.waitFor(() => { + const output = result.terminal.output.slice(runningSelectorOutput) + expect(output).toContain('Select model') + expect(output).toContain('alpha/a1') + expect(output).toContain('Alpha One — Fast — current') + }) result.terminal.send('\x1b[B') result.terminal.send('\x1b[B') result.terminal.send('\r') @@ -2276,9 +2281,12 @@ describe('pi-tui chat lifecycle and transcript', () => { await tick() expect(result.terminal.output).not.toContain('50% context tools:collapsed') + const cancelledSelectorOutput = result.terminal.output.length result.terminal.send('/model') result.terminal.send('\r') - await tick() + await vi.waitFor(() => { + expect(result.terminal.output.slice(cancelledSelectorOutput)).toContain('Select model') + }) result.terminal.send('\x1b') await tick() expect(result.agent.cancelled).not.toContain('cancelled from terminal') From 33ee34b58ea0d46601e544b589133f48f8ae580f Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:40:17 -0700 Subject: [PATCH 13/15] fix(tui): make resume picker full-screen --- .../2026-07-21-tui-resume-command.i18n.yaml | 4 +- .../feature/2026-07-21-tui-resume-command.md | 4 +- .../2026-07-21-tui-resume-command.zh.md | 4 +- docs/config-catalog.md | 6 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 2 +- packages/ui/tui/README.md | 4 +- packages/ui/tui/src/index.ts | 146 +++++++++++------- .../snapshots/resume-sessions.expected.txt | 112 ++++++-------- packages/ui/tui/tests/tui.spec.ts | 20 +-- 9 files changed, 156 insertions(+), 146 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index 62dc61c019..d470b61414 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 526c3775bcae1bae63fb37b097091f83cfc67afd -2026-07-21-tui-resume-command.zh.md: d333a5bb22057d3d035c22950a84f51e0ca0640d +2026-07-21-tui-resume-command.md: 86f62e16f5e2ee83e2ed36f0ed675ca2a1422c4b +2026-07-21-tui-resume-command.zh.md: 06e58f81445aaaf5299282714148194c1d2aacf4 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 526c3775bc..86f62e16f5 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -10,7 +10,7 @@ The original `/resume` printed shell commands. It did not let a keyboard user in ## Decision -`/resume` uses the TUI's existing interactive overlay seam. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. +`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. `session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, `cwd`, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process. @@ -36,4 +36,4 @@ After preflight, the TUI flushes the current session, confirms that its agent re ## Testing -TUI tests cover keyboard navigation, title/id search, Escape cancellation, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the visible selector frame. +TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index d333a5bb22..06e58f8144 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -`/resume` 使用 TUI 现有的交互式浮层接口。它按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 +`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 `session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。 @@ -36,4 +36,4 @@ Status: implemented ## Testing -TUI 测试覆盖键盘导航、标题/id 搜索、按 Escape 取消、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定用户可见的选择器画面。 +TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 040b15baf3..49a0a1510f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1609,10 +1609,6 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number - /** Resume-selector width in terminal columns. */ - resumeDialogWidth?: number - /** Resume-selector maximum height in terminal rows. */ - resumeDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -1635,7 +1631,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:278`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:270`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-tui-demo` diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 8c93a004c2..efbe11099f 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -270,7 +270,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { actions: [ { waitFor: 'scripted TUI ready.', send: '/resume\r' }, { waitFor: 'Resume selector design', send: 'Resume selector design' }, - { waitFor: 'Search: Resume selector design', send: '\r' }, + { waitFor: '⌕ Resume selector design', send: '\r' }, { waitFor: 'Preserve restored state', send: '/exit\r' }, ], }) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 8dc10f20f8..3dae79bf73 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -30,7 +30,7 @@ The footer sums the session's reported usage as `↑ `/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer. -`/resume` opens a keyboard selector over the current workspace. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. +`/resume` opens a full-viewport keyboard selector over the current workspace instead of a centered dialog. Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a mismatched cwd, or a session whose logged provider has no current adapter remains visible but disabled. Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume`; where `process.execve` is available, the shipped `dsh` host disposes the app and replaces its process. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. `resumeCommand` remains the deployment-owned fallback: exiting prints it only after the current session is durable, and a host without in-place handoff shows the selected session's command. `{session}` expands to the session id. TUI code never executes the template or arbitrary shell text. @@ -49,8 +49,6 @@ The footer sums the session's reported usage as `↑ | `questionDialogMaxHeight` | `20` | Question-panel maximum rows | | `modelDialogWidth` | `72` | Model-selector width in columns | | `modelDialogMaxHeight` | `20` | Model-selector maximum rows | -| `resumeDialogWidth` | `88` | Resume-selector width in columns | -| `resumeDialogMaxHeight` | `24` | Resume-selector maximum rows | | `fileSearchMaxResults` | `20` | Maximum file and directory candidates shown for one `@` query | | `fileSearchMaxEntries` | `10000` | Maximum paths retained in the bounded workspace index used by bare fuzzy queries | | `fileSearchExcludedDirectories` | `['.git', 'node_modules']` | Directory basenames omitted from traversal and direct completion | diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 372a0c9497..2f7aab15af 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -205,10 +205,6 @@ export interface TuiConfig { modelDialogWidth?: number /** Model-selector maximum height in terminal rows. */ modelDialogMaxHeight?: number - /** Resume-selector width in terminal columns. */ - resumeDialogWidth?: number - /** Resume-selector maximum height in terminal rows. */ - resumeDialogMaxHeight?: number /** Maximum fuzzy file candidates displayed for one `@` query. */ fileSearchMaxResults?: number /** Maximum paths retained in one `@` workspace index. */ @@ -239,8 +235,6 @@ const questionDialogWidthSchema = z.number().step(1).min(20).default(200) const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) const modelDialogWidthSchema = z.number().step(1).min(20).default(72) const modelDialogMaxHeightSchema = z.number().step(1).min(6).default(20) -const resumeDialogWidthSchema = z.number().step(1).min(36).default(88) -const resumeDialogMaxHeightSchema = z.number().step(1).min(8).default(24) const fileSearchMaxResultsSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_RESULTS) const fileSearchMaxEntriesSchema = z.number().step(1).min(1).default(DEFAULT_FILE_SEARCH_MAX_ENTRIES) const fileSearchExcludedDirectoriesSchema = z.array(z.string()).default([...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES]) @@ -260,8 +254,6 @@ const tuiConfigSchemaFields = { questionDialogMaxHeight: questionDialogMaxHeightSchema, modelDialogWidth: modelDialogWidthSchema, modelDialogMaxHeight: modelDialogMaxHeightSchema, - resumeDialogWidth: resumeDialogWidthSchema, - resumeDialogMaxHeight: resumeDialogMaxHeightSchema, fileSearchMaxResults: fileSearchMaxResultsSchema, fileSearchMaxEntries: fileSearchMaxEntriesSchema, fileSearchExcludedDirectories: fileSearchExcludedDirectoriesSchema, @@ -302,8 +294,6 @@ export const Config: z = z.object({ questionDialogMaxHeight: tuiConfigSchemaFields.questionDialogMaxHeight, modelDialogWidth: tuiConfigSchemaFields.modelDialogWidth, modelDialogMaxHeight: tuiConfigSchemaFields.modelDialogMaxHeight, - resumeDialogWidth: tuiConfigSchemaFields.resumeDialogWidth, - resumeDialogMaxHeight: tuiConfigSchemaFields.resumeDialogMaxHeight, fileSearchMaxResults: tuiConfigSchemaFields.fileSearchMaxResults, fileSearchMaxEntries: tuiConfigSchemaFields.fileSearchMaxEntries, fileSearchExcludedDirectories: tuiConfigSchemaFields.fileSearchExcludedDirectories, @@ -324,8 +314,6 @@ export interface ResolvedTuiConfig { questionDialogMaxHeight: number modelDialogWidth: number modelDialogMaxHeight: number - resumeDialogWidth: number - resumeDialogMaxHeight: number fileSearchMaxResults: number fileSearchMaxEntries: number fileSearchExcludedDirectories: string[] @@ -370,8 +358,6 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, modelDialogWidth: config?.modelDialogWidth ?? 72, modelDialogMaxHeight: config?.modelDialogMaxHeight ?? 20, - resumeDialogWidth: config?.resumeDialogWidth ?? 88, - resumeDialogMaxHeight: config?.resumeDialogMaxHeight ?? 24, fileSearchMaxResults: config?.fileSearchMaxResults ?? DEFAULT_FILE_SEARCH_MAX_RESULTS, fileSearchMaxEntries: config?.fileSearchMaxEntries ?? DEFAULT_FILE_SEARCH_MAX_ENTRIES, fileSearchExcludedDirectories: [...(config?.fileSearchExcludedDirectories ?? DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES)], @@ -1346,9 +1332,9 @@ function summarizeResumeCandidate( } } -/** Searchable keyboard selector over detached, preflighted resume summaries. */ -class ResumeDialog implements Component, Focusable { - private query = '' +/** Full-viewport keyboard selector over detached, preflighted resume summaries. */ +class ResumePicker implements Component, Focusable { + private readonly search = new Input() private selectedIndex = 0 private error = '' focused = false @@ -1356,87 +1342,133 @@ class ResumeDialog implements Component, Focusable { constructor( private readonly candidates: readonly ResumeCandidate[], private readonly maxVisible: number, + private readonly workspaceLabel: string, + private readonly viewportRows: () => number, private readonly palette: Palette, private readonly done: (candidate: ResumeCandidate) => void, private readonly cancel: () => void, ) {} - invalidate(): void {} + invalidate(): void { + this.search.invalidate() + } private filtered(): ResumeCandidate[] { - const query = this.query.trim().toLocaleLowerCase() + const query = this.search.getValue().trim().toLocaleLowerCase() if (query === '') return [...this.candidates] return this.candidates.filter(candidate => candidate.title.toLocaleLowerCase().includes(query) || candidate.record.header.id.toLocaleLowerCase().includes(query)) } handleInput(data: string): void { - this.invalidate() const filtered = this.filtered() - if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { + if (matchesKey(data, Key.ctrl('c'))) { this.cancel() return } - if (matchesKey(data, Key.up)) { + if (matchesKey(data, Key.escape)) { + if (this.search.getValue() === '') this.cancel() + else { + this.search.setValue('') + this.selectedIndex = 0 + this.error = '' + } + } else if (matchesKey(data, Key.up)) { this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + filtered.length - 1) % filtered.length } else if (matchesKey(data, Key.down)) { this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length + } else if (matchesKey(data, Key.pageUp)) { + this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible) + } else if (matchesKey(data, Key.pageDown)) { + this.selectedIndex = Math.min( + Math.max(0, filtered.length - 1), + this.selectedIndex + this.maxVisible, + ) } else if (matchesKey(data, Key.enter)) { const selected = filtered[this.selectedIndex] if (selected === undefined) this.error = 'No session matches this search.' else if (selected.disabledReason !== undefined) this.error = selected.disabledReason else this.done(selected) - } else if (data === '\x7f' || data === '\b') { - this.query = Array.from(this.query).slice(0, -1).join('') - this.selectedIndex = 0 - this.error = '' - } else if (!Array.from(data).some(character => character < ' ' || character === '\x7f')) { - this.query += data - this.selectedIndex = 0 - this.error = '' + } else { + const previous = this.search.getValue() + this.search.focused = this.focused + this.search.handleInput(data) + if (this.search.getValue() !== previous) { + this.selectedIndex = 0 + this.error = '' + } } + this.invalidate() } render(width: number): string[] { - const innerWidth = Math.max(1, width - 4) + this.search.focused = this.focused + const height = Math.max(1, this.viewportRows()) + const horizontalPadding = width >= 12 ? 2 : 0 + const contentWidth = Math.max(1, width - horizontalPadding * 2) + const indent = ' '.repeat(horizontalPadding) const filtered = this.filtered() if (this.selectedIndex >= filtered.length) this.selectedIndex = Math.max(0, filtered.length - 1) - const start = Math.max(0, Math.min( - this.selectedIndex - Math.floor(this.maxVisible / 2), - filtered.length - this.maxVisible, - )) - const end = Math.min(filtered.length, start + this.maxVisible) - const body: string[] = [ - this.query === '' - ? `${this.palette.muted('Search:')} ${this.palette.dim('title or session id')}` - : this.palette.text(`Search: ${displayText(this.query)}`), + const selected = filtered[this.selectedIndex] + const position = selected === undefined ? 0 : this.selectedIndex + 1 + const lines: string[] = [ + '', + `${indent}${this.palette.bold(this.palette.accent(`Resume session (${position} of ${filtered.length})`))}`, '', ] + + const searchInnerWidth = Math.max(1, contentWidth - 4) + lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) + const searchContent = (this.search.render(searchInnerWidth)[0] ?? '').replace(/^> /u, '⌕ ') + const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') + lines.push( + `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, + `${indent}${this.palette.dim(`╰${'─'.repeat(Math.max(0, contentWidth - 2))}╯`)}`, + '', + `${indent}${this.palette.muted(displayText(this.workspaceLabel))}`, + '', + ) + + const candidateBudget = Math.max(1, Math.floor((height - 13) / 4)) + const visibleCount = Math.min(this.maxVisible, candidateBudget) + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(visibleCount / 2), + filtered.length - visibleCount, + )) + const end = Math.min(filtered.length, start + visibleCount) + const push = (line: string): void => { + lines.push(`${indent}${truncateToWidth(line, contentWidth, '…')}`) + } for (let index = start; index < end; index += 1) { const candidate = filtered[index] as ResumeCandidate - const selected = index === this.selectedIndex + const active = index === this.selectedIndex const status = [ candidate.disabledReason === 'current session' ? 'current' : undefined, candidate.record.live ? 'live' : undefined, candidate.record.persisted ? 'persisted' : undefined, ].filter((value): value is string => value !== undefined).join(' · ') - const lead = `${selected ? '›' : ' '} ${displayText(candidate.title)}` - body.push(selected ? this.palette.bold(this.palette.accent(lead)) : lead) + const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` + push(active ? this.palette.bold(this.palette.accent(lead)) : lead) const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` - body.push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) - body.push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + push(this.palette.muted(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) + push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) if (candidate.disabledReason !== undefined) { - body.push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) + push(this.palette.warning(` unavailable: ${displayText(candidate.disabledReason)}`)) } } - if (filtered.length === 0) body.push(this.palette.warning('No matching sessions.')) - if (filtered.length > this.maxVisible) body.push(this.palette.dim(`${this.selectedIndex + 1}/${filtered.length}`)) - body.push('', this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc cancel')) - if (this.error !== '') body.push(this.palette.error(displayText(this.error))) - return renderDialog('Resume session', body.flatMap(line => wrapTextWithAnsi(line, innerWidth)), width, this.palette) + if (filtered.length === 0) push(this.palette.warning('No matching sessions.')) + if (this.error !== '') { + lines.push('') + push(this.palette.error(displayText(this.error))) + } + + const footer = `${indent}${this.palette.dim('Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel')}` + while (lines.length < height - 2) lines.push('') + lines.push(footer, '') + return lines.slice(0, height) } } @@ -2932,18 +2964,20 @@ export function createTuiChat( || a.record.header.id.localeCompare(b.record.header.id)) if (isDisposed() || scan !== resumeScan) return const session = overlayManager.open({ - create: () => new ResumeDialog( + create: host => new ResumePicker( candidates, resolved.maxResumeOptions, + runtime.formatCwd?.(agent.session.header.cwd) ?? formatCwd(agent.session.header.cwd), + () => host.viewport.rows, palette, (candidate) => { void handoffResume(candidate, session) }, () => { void session.close() }, ), options: { - width: resolved.resumeDialogWidth, - maxHeight: resolved.resumeDialogMaxHeight, - anchor: 'center', - margin: 1, + width: '100%', + maxHeight: '100%', + anchor: 'top-left', + margin: 0, }, }) resumeOverlay = session diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt index d78aa6d31f..db54654115 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -1,69 +1,51 @@ terminal 92x32 buffer=normal length=32 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=0 viewportRow=31 bufferRow=31 +cursor hidden column=6 viewportRow=4 bufferRow=4 buffer -0| " DEEPSEEK HARNESS" - style 1-8 fg=bright-blue bold - style 10-16 bold -1| " Snapshot agent ready." - style 1-21 fg=bright-black -2| " deepseek-v4-flash • main-session" - style 1-34 dim -3| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -4| " " - style 1-1 inverse -5| "────────────────────────────────────────────────────────────────────────────────────────────" - style 0-91 dim -6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed" - style 0-43 dim - style 65-91 dim -7-8| -9| " ╭ Resume session ──────────────────────────────────────────────────────────────────────╮ " - style 2-89 fg=bright-blue -10| " │ Search: title or session id │ " - style 2-2 fg=bright-blue - style 4-10 fg=bright-black - style 12-30 dim - style 89-89 fg=bright-blue -11| " │ │ " - style 2-2 fg=bright-blue - style 89-89 fg=bright-blue -12| " │ › Untitled session │ " - style 2-2 fg=bright-blue - style 4-21 fg=bright-blue bold - style 89-89 fg=bright-blue -13| " │ 2026-07-23T08:00:00.000Z · no completed turn · route unavailable │ " - style 2-2 fg=bright-blue - style 4-69 fg=bright-black - style 89-89 fg=bright-blue -14| " │ current · live · main-session │ " - style 2-2 fg=bright-blue - style 4-34 dim - style 89-89 fg=bright-blue -15| " │ unavailable: current session │ " - style 2-2 fg=bright-blue - style 4-33 fg=yellow - style 89-89 fg=bright-blue -16| " │ Resume selector design │ " - style 2-2 fg=bright-blue - style 89-89 fg=bright-blue -17| " │ 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro │ " - style 2-2 fg=bright-blue - style 4-76 fg=bright-black - style 89-89 fg=bright-blue -18| " │ persisted · earlier-session │ " - style 2-2 fg=bright-blue - style 4-32 dim - style 89-89 fg=bright-blue -19| " │ │ " - style 2-2 fg=bright-blue - style 89-89 fg=bright-blue -20| " │ Type to search • ↑/↓ navigate • Enter resume • Esc cancel │ " - style 2-2 fg=bright-blue - style 4-60 dim - style 89-89 fg=bright-blue -21| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ " - style 2-89 fg=bright-blue -22-31| +0| " " +1| " Resume session (1 of 2) " + style 2-24 fg=bright-blue bold +2| " " +3| " ╭──────────────────────────────────────────────────────────────────────────────────────╮ " + style 2-89 dim +4| " │ ⌕ │ " + style 2-2 dim + style 6-6 inverse + style 89-89 dim +5| " ╰──────────────────────────────────────────────────────────────────────────────────────╯ " + style 2-89 dim +6| " " +7| " /workspace/project " + style 2-19 fg=bright-black +8| " " +9| " ❯ Untitled session " + style 2-19 fg=bright-blue bold +10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable " + style 2-67 fg=bright-black +11| " current · live · main-session " + style 2-32 dim +12| " unavailable: current session " + style 2-31 fg=yellow +13| " Resume selector design " +14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek/deepseek-v4-pro " + style 2-74 fg=bright-black +15| " persisted · earlier-session " + style 2-30 dim +16| " " +17| " " +18| " " +19| " " +20| " " +21| " " +22| " " +23| " " +24| " " +25| " " +26| " " +27| " " +28| " " +29| " " +30| " Type to search • ↑/↓ navigate • Enter resume • Esc clear/cancel " + style 2-70 dim +31| " " diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index c6426d4c67..127d5c8807 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -159,8 +159,6 @@ describe('TUI config', () => { questionDialogMaxHeight: 20, modelDialogWidth: 72, modelDialogMaxHeight: 20, - resumeDialogWidth: 88, - resumeDialogMaxHeight: 24, fileSearchMaxResults: 20, fileSearchMaxEntries: 10_000, fileSearchExcludedDirectories: ['.git', 'node_modules'], @@ -179,8 +177,6 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, - resumeDialogWidth: 84, - resumeDialogMaxHeight: 22, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -198,8 +194,6 @@ describe('TUI config', () => { questionDialogMaxHeight: 14, modelDialogWidth: 64, modelDialogMaxHeight: 16, - resumeDialogWidth: 84, - resumeDialogMaxHeight: 22, fileSearchMaxResults: 7, fileSearchMaxEntries: 123, fileSearchExcludedDirectories: ['.git', 'generated'], @@ -269,7 +263,7 @@ describe('resume command and /resume', () => { await dispose(result) }) - it('opens a newest-active-first searchable selector and Esc cancels without side effects', async () => { + it('opens a newest-active-first searchable selector and Esc clears before cancelling', async () => { const older = header('older-session', 500, '/workspace') const newer = header('newer-session', 2000, '/workspace') const handoff = vi.fn>() @@ -296,7 +290,11 @@ describe('resume command and /resume', () => { expect(output).not.toContain('foreign-session') result.terminal.send('Older') await tick() - expect(result.terminal.output).toContain('Search: Older') + expect(result.terminal.output).toContain('⌕ Older') + result.terminal.send('\x1b') + await tick() + expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) + .not.toContain('⌕ Older') result.terminal.send('\x1b') await tick() expect(handoff).not.toHaveBeenCalled() @@ -325,7 +323,9 @@ describe('resume command and /resume', () => { result.terminal.send('\x7f') result.terminal.send('\x7f') await tick() - expect(result.terminal.output).toContain('Search: title or session id') + const cleared = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) + expect(cleared).toContain('⌕ ') + expect(cleared).not.toContain('zz') result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('current session') @@ -349,7 +349,7 @@ describe('resume command and /resume', () => { result.terminal.send('/resume') result.terminal.send('\r') await tick(); await tick() - expect(result.terminal.output).toContain('1/3') + expect(result.terminal.output).toContain('(1 of 3)') await dispose(result) }) From 12dbc001665e472d2f970a267fc9cc150ecb01c9 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:15:26 -0700 Subject: [PATCH 14/15] fix(tui): harden resume picker input --- packages/ui/tui/src/index.ts | 56 ++++++++++++++++++++++++++--- packages/ui/tui/tests/tui.spec.ts | 59 +++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 2f7aab15af..2ade1937e3 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -393,6 +393,11 @@ function ansi(open: string, close: string, enabled: boolean): (text: string) => } const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu +const TERMINAL_OSC_PATTERN = /(?:\u001B\]|\u009D)(?:(?!\u0007|\u001B\\)[\s\S])*(?:\u0007|\u001B\\|$)/gu +const TERMINAL_CSI_PATTERN = /(?:\u001B\[|\u009B)[0-?]*[ -/]*[@-~]/gu +const TERMINAL_ESCAPE_PATTERN = /\u001B[@-_]/gu +const BRACKETED_PASTE_START = '\u001B[200~' +const BRACKETED_PASTE_END = '\u001B[201~' /** * Escape external C0/C1 controls before pi-tui adds application-owned ANSI. @@ -408,6 +413,15 @@ function displayInlineText(text: string): string { return displayText(text).replaceAll('\n', '\\x0a') } +/** Remove terminal controls from clipboard text before an editable field stores it. */ +function sanitizePastedText(text: string): string { + return text + .replace(TERMINAL_OSC_PATTERN, '') + .replace(TERMINAL_CSI_PATTERN, '') + .replace(TERMINAL_ESCAPE_PATTERN, '') + .replace(TERMINAL_CONTROL_PATTERN, '') +} + /** * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR * attributes, which every terminal remaps to its active color scheme. Body @@ -1335,6 +1349,7 @@ function summarizeResumeCandidate( /** Full-viewport keyboard selector over detached, preflighted resume summaries. */ class ResumePicker implements Component, Focusable { private readonly search = new Input() + private pasteBuffer: string | undefined private selectedIndex = 0 private error = '' focused = false @@ -1360,7 +1375,39 @@ class ResumePicker implements Component, Focusable { || candidate.record.header.id.toLocaleLowerCase().includes(query)) } + private visibleCandidateCount(): number { + const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / 4)) + return Math.min(this.maxVisible, candidateBudget) + } + + private handleBracketedPaste(data: string): boolean { + const start = data.indexOf(BRACKETED_PASTE_START) + if (this.pasteBuffer === undefined && start < 0) return false + if (this.pasteBuffer === undefined) { + const prefix = data.slice(0, start) + if (prefix !== '') this.handleInput(prefix) + this.pasteBuffer = data.slice(start + BRACKETED_PASTE_START.length) + } else { + this.pasteBuffer += data + } + const end = this.pasteBuffer.indexOf(BRACKETED_PASTE_END) + if (end < 0) return true + const pasted = sanitizePastedText(this.pasteBuffer.slice(0, end)) + const remaining = this.pasteBuffer.slice(end + BRACKETED_PASTE_END.length) + this.pasteBuffer = undefined + const previous = this.search.getValue() + this.search.handleInput(`${BRACKETED_PASTE_START}${pasted}${BRACKETED_PASTE_END}`) + if (this.search.getValue() !== previous) { + this.selectedIndex = 0 + this.error = '' + } + if (remaining !== '') this.handleInput(remaining) + this.invalidate() + return true + } + handleInput(data: string): void { + if (this.handleBracketedPaste(data)) return const filtered = this.filtered() if (matchesKey(data, Key.ctrl('c'))) { this.cancel() @@ -1380,11 +1427,11 @@ class ResumePicker implements Component, Focusable { } else if (matchesKey(data, Key.down)) { this.selectedIndex = filtered.length === 0 ? 0 : (this.selectedIndex + 1) % filtered.length } else if (matchesKey(data, Key.pageUp)) { - this.selectedIndex = Math.max(0, this.selectedIndex - this.maxVisible) + this.selectedIndex = Math.max(0, this.selectedIndex - this.visibleCandidateCount()) } else if (matchesKey(data, Key.pageDown)) { this.selectedIndex = Math.min( Math.max(0, filtered.length - 1), - this.selectedIndex + this.maxVisible, + this.selectedIndex + this.visibleCandidateCount(), ) } else if (matchesKey(data, Key.enter)) { const selected = filtered[this.selectedIndex] @@ -1421,7 +1468,7 @@ class ResumePicker implements Component, Focusable { const searchInnerWidth = Math.max(1, contentWidth - 4) lines.push(`${indent}${this.palette.dim(`╭${'─'.repeat(Math.max(0, contentWidth - 2))}╮`)}`) - const searchContent = (this.search.render(searchInnerWidth)[0] ?? '').replace(/^> /u, '⌕ ') + const searchContent = this.search.render(searchInnerWidth).join('').replace(/^> /u, '⌕ ') const clippedSearch = truncateToWidth(searchContent, searchInnerWidth, '') lines.push( `${indent}${this.palette.dim('│')} ${clippedSearch}${' '.repeat(Math.max(0, searchInnerWidth - visibleWidth(clippedSearch)))} ${this.palette.dim('│')}`, @@ -1431,8 +1478,7 @@ class ResumePicker implements Component, Focusable { '', ) - const candidateBudget = Math.max(1, Math.floor((height - 13) / 4)) - const visibleCount = Math.min(this.maxVisible, candidateBudget) + const visibleCount = this.visibleCandidateCount() const start = Math.max(0, Math.min( this.selectedIndex - Math.floor(visibleCount / 2), filtered.length - visibleCount, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 127d5c8807..73be9abe76 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -333,6 +333,65 @@ describe('resume command and /resume', () => { await dispose(result) }) + it('sanitizes bracketed-paste terminal controls before storing the search query', async () => { + const target = header('safe-target', 10, '/workspace') + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => [target], + load: async () => ({ meta: target, events: resumeEvents('Safe target') }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('\x1b[200~Safe\x1b]0;own') + result.terminal.send('ed\x07 target\x1b[31m\x1b[201~') + await tick() + const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) + expect(rendered).toContain('⌕ Safe target') + expect(rendered).not.toContain('owned') + expect(rendered).not.toContain('[31m') + result.terminal.send('\x1b') + result.terminal.send('Safe\x1b[200~\x1b[201~ target') + await tick() + expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) + .toContain('⌕ Safe target') + await dispose(result) + }) + + it('pages by the number of candidates that fit the current viewport', async () => { + const targets = Array.from({ length: 8 }, (_, index) => + header(`paged-${index}`, 1000 - index, '/workspace')) + const result = await setup({ + cwd: '/workspace', + sessionPersistence: { + list: async () => targets, + load: async id => ({ + meta: targets.find(target => target.id === id)!, + events: resumeEvents(`Paged ${id.slice('paged-'.length)}`, 'deepseek', 1000 - Number(id.slice('paged-'.length)) * 10), + }), + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('\x1b[6~') + await tick() + const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) + expect(rendered).toContain('❯ Paged 3') + result.terminal.send('\x1b[5~') + await tick() + expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) + .toContain('❯ Untitled session') + result.terminal.resize(10) + await tick() + expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) + .toContain('⌕') + result.terminal.send('\x03') + await dispose(result) + }) + it('clips candidate count through the configured visible-session limit', async () => { const targets = [header('limited-a', 10, '/workspace'), header('limited-b', 20, '/workspace')] const result = await setup({ From 6fc92bbb163e7361dc91014c83593c271c960f39 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Fri, 24 Jul 2026 16:32:35 +0800 Subject: [PATCH 15/15] fix: ci --- .../ui-conversation/tests/skeleton-branches.spec.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx index d1bd50437f..4eb70ea39f 100644 --- a/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton-branches.spec.tsx @@ -3,7 +3,7 @@ // acceptance flows), four-share props form: breadcrumb ancestry derivation + // error strip in ConversationRoot, DetailsPanel non-JSON args / non-text // result blocks / error-only results over the shared store, EmptyState -// failure surface and custom-directory swap with in-component cwd derivation. +// failure surface and path-modal confirm with in-component cwd derivation. import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' @@ -271,7 +271,7 @@ describe('EmptyState branches', () => { await waitFor(() => expect(view.getByText(/发送失败:plain-string/)).toBeTruthy()) }) - it('cwd derivation skips blank cwds; menu picks, swaps to free-form, submits the typed path', async () => { + it('cwd derivation skips blank cwds; menu picks, path modal confirms, submits the typed path', async () => { const startSession = vi.fn(() => Promise.resolve()) const view = render( { fireEvent.click(view.getByRole('button', { name: '项目目录' })) fireEvent.mouseEnter(view.getByRole('menuitem', { name: 'New Workspace' }).parentElement as HTMLElement) fireEvent.click(view.getByRole('menuitem', { name: 'Use a existing folder' })) - const custom = view.container.querySelector('input')! + const custom = view.getByLabelText('Folder path') fireEvent.change(custom, { target: { value: '/typed/dir' } }) + fireEvent.click(view.getByRole('button', { name: 'Open Folder' })) const textarea = view.container.querySelector('textarea')! fireEvent.change(textarea, { target: { value: 'task' } }) fireEvent.keyDown(textarea, { key: 'Enter' })