From 0f33ac634c97f4ee64e574dd2a52e0bb018e0b2a Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Tue, 8 Sep 2026 18:10:51 +0800 Subject: [PATCH 1/3] feat: fork on a user prompt rewinds and refills the composer Forking at a user prompt used to copy that prompt into the new session history verbatim. It now rewinds to the state just before the prompt and returns the prompt text as restored_prompt so the UI can refill the composer for the user to edit and resend. Forking at an assistant message keeps the previous behaviour unchanged. Backend: resolve_fork_anchor_index scans back past meta/checkpoint/ timing/net-diff records and refuses to stop on an assistant message with unanswered tool calls, so the forked prefix never ends mid-tool-call. fork_restored_prompt_text falls back to joining text parts when a multimodal user message carries no plain content. Frontend: forkAndSwitch sets the composer-preserve flag before switching sessions, then refills from restored_prompt. Tests: unit tests for anchor resolution and prompt extraction, plus HTTP route tests asserting restored_prompt for user targets and its absence for assistant targets. OpenSpec change fork-user-prompt-to-composer. --- .../.openspec.yaml | 2 + .../fork-user-prompt-to-composer/design.md | 100 +++++++++++ .../fork-user-prompt-to-composer/proposal.md | 60 +++++++ .../specs/session-fork-anchor/spec.md | 90 ++++++++++ .../fork-user-prompt-to-composer/tasks.md | 26 +++ src/session/session_rewind.cpp | 60 +++++++ src/session/session_rewind.hpp | 24 +++ src/web/routes/routes_sessions.cpp | 22 ++- tests/session/session_rewind_test.cpp | 165 ++++++++++++++++++ tests/web/web_server_smoke_test.cpp | 101 +++++++++++ web/scripts/i18n-en-overrides.mjs | 2 + web/src/components/ChatView.jsx | 17 +- web/src/i18n/sourceCatalog.generated.js | 2 + web/src/lib/runTests.js | 1 + web/src/lib/sessionFork.js | 9 + web/src/lib/sessionFork.test.js | 24 +++ 16 files changed, 702 insertions(+), 3 deletions(-) create mode 100644 openspec/changes/fork-user-prompt-to-composer/.openspec.yaml create mode 100644 openspec/changes/fork-user-prompt-to-composer/design.md create mode 100644 openspec/changes/fork-user-prompt-to-composer/proposal.md create mode 100644 openspec/changes/fork-user-prompt-to-composer/specs/session-fork-anchor/spec.md create mode 100644 openspec/changes/fork-user-prompt-to-composer/tasks.md create mode 100644 web/src/lib/sessionFork.js create mode 100644 web/src/lib/sessionFork.test.js diff --git a/openspec/changes/fork-user-prompt-to-composer/.openspec.yaml b/openspec/changes/fork-user-prompt-to-composer/.openspec.yaml new file mode 100644 index 00000000..7a8e2be6 --- /dev/null +++ b/openspec/changes/fork-user-prompt-to-composer/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-08 diff --git a/openspec/changes/fork-user-prompt-to-composer/design.md b/openspec/changes/fork-user-prompt-to-composer/design.md new file mode 100644 index 00000000..c4a9f156 --- /dev/null +++ b/openspec/changes/fork-user-prompt-to-composer/design.md @@ -0,0 +1,100 @@ +## Context + +`POST /api/sessions//fork` takes `at_message_id`, finds its index, and keeps +`retained_prefix_before_index(messages, idx + 1)` — the prefix **including** the +clicked message. `SessionManager::fork_session_to_new_id` then copies that prefix +into a fresh JSONL, filtering file checkpoints, turn timing, and turn net diff +records, and re-attaching timing/diff rows per retained user uuid. + +Messages are a flat JSONL array (`ChatMessage` in +`src/provider/llm_provider.hpp:14`); there is no parent/child tree, so "the +previous agent summary" has to be located by scanning backwards. + +The Web client's `forkAndSwitch` (`web/src/components/ChatView.jsx:3390`) only +toasts and switches; it never touches the composer. Note that switching sessions +reloads the composer draft in an effect at `:1680`, which clears the value unless +`preserveComposerInputOnSessionChangeRef` is set first. + +## Goals / Non-Goals + +**Goals:** + +- Forking on a user message produces a session that ends at the state **before** + that prompt ran. +- The clicked prompt's text is refilled into the composer so it can be edited and + resubmitted. +- Same behavior on desktop (context menu) and Web (hover action) since both share + `forkAndSwitch`. +- Never leave a dangling assistant `tool_calls` message without its results. + +**Non-Goals:** + +- Auto-submitting the refilled prompt. (Goose does this; Codex and Grok Build do + not. Waiting for the user is the safer default and matches the request.) +- Restoring attachments or `content_parts`. Only the plain text is refilled. +- Changing fork behavior for assistant messages. +- Changing the on-disk session schema or the compact/checkpoint model. + +## Decisions + +1. **Anchor = first real message at or before `idx - 1`.** + + Scan backwards from the clicked user message and skip records that are not + part of the conversation: `is_meta`, file checkpoints, turn timing, and turn + net diff (the same predicates `fork_session_to_new_id` already filters). Stop + at the first remaining message and keep `[0..j]`. + + If nothing real exists before the clicked message (it is the first message), + keep an empty prefix. + +2. **Stop at a `tool` result; do not skip it looking for an assistant.** + + This is the subtle part. An agent turn is + `assistant(tool_calls)` → `tool(result)` → `assistant(summary)`. Because the + result comes *after* the call, truncating at the result keeps a complete pair, + while skipping the result and stopping on the calling assistant would leave a + `tool_calls` message with no response, which providers reject. Grok Build + relies on the same invariant by always cutting at user boundaries. + + As a defensive extra, if the anchor lands on an assistant message that still + has unsatisfied `tool_calls`, keep scanning backwards. Goose omits this guard. + +3. **Consecutive user messages keep everything except the clicked one.** + + If the scan reaches another user message first, stop there. A user who sends + two prompts in a row and forks on the second one expects the first to survive; + silently dropping it would lose work. + +4. **The server is the single source of truth for the restored prompt.** + + The fork response gains `restored_prompt` (plain text of the clicked user + message) and `fork_anchor_role`. The client refills whenever + `restored_prompt` is present, so front-end and back-end cannot disagree about + whether the message was a user prompt, and the client does not need the + message list to be fully loaded. + +5. **Refill before switching, and set the preserve flag.** + + In `forkAndSwitch`, set `preserveComposerInputOnSessionChangeRef.current = + true` and `setComposerValue(restored_prompt)` before promoting the new + session. Without the flag the session-switch effect reloads the draft and + wipes the value. + +6. **Only plain text is refilled.** + + `content_parts` may carry images or files. Restoring them would require the + composer to accept prefilled attachments, which is out of scope. + +## Risks / Trade-offs + +- **A fork can now produce an empty session.** Forking on the very first user + message keeps no history. This is intended (it is how you restart a session + with a reworded first prompt), but the empty state must render cleanly. +- **Attachments on the original prompt are silently dropped.** Accepted for now; + the text is refilled and the user can re-attach. Revisit if it causes + confusion. +- **Resubmitting unchanged is allowed.** It simply replays the turn in the new + session and cannot affect the source session. +- **Anchor resolution depends on the meta predicates staying in sync** with + `fork_session_to_new_id`. Reuse the existing helpers rather than duplicating + the conditions so both evolve together. diff --git a/openspec/changes/fork-user-prompt-to-composer/proposal.md b/openspec/changes/fork-user-prompt-to-composer/proposal.md new file mode 100644 index 00000000..33600215 --- /dev/null +++ b/openspec/changes/fork-user-prompt-to-composer/proposal.md @@ -0,0 +1,60 @@ +## Why + +Forking a session currently copies the prefix `[0..idx]` including the clicked +message itself (`retained_prefix_before_index` in `src/session/session_rewind.cpp` +called from `src/web/routes/fork_handler.cpp`). When the clicked message is a +user prompt, the new session therefore starts with that prompt already committed +to history, and the TUI/Web composer stays empty. + +That is the wrong affordance for the most common reason to fork on a user +message: the user wants to **rephrase that prompt and try again** from the state +before it ran. Today they must manually retype or copy the prompt after forking. + +Every competitor that implements this behavior forks *before* the selected user +turn rather than including it: + +- Codex backtrack (`codex-rs/tui/src/app_backtrack.rs:6-14`): "forks before the + selected turn and restores its prompt in the new composer." +- Grok Build `/rewind` (`xai-grok-shell/src/session/acp_session_impl/rewind.rs:393`): + "Rewind to N restores state from before prompt N ran; prompts 0..N-1 are kept", + then refills the composer **without** auto-submitting. +- Goose fork (`crates/goose/src/session/session_manager.rs:2545`): deletes rows + with `created_timestamp >= ?`, i.e. removes the clicked user message. +- Cline `restoreCheckpoint`: `slice(0, targetIndex)` then stores + `checkpointRestoreInput` which the webview writes into the textarea. + +## What Changes + +- Resolve the fork anchor by scanning backwards from the clicked user message, + skipping non-conversational records (file checkpoint, turn timing, turn net + diff, meta messages), and stopping at the first real message. +- When the clicked message is a user message, fork **before** it and return its + text so the client can refill the composer. +- The client (desktop context menu and Web message hover action both funnel + through `forkAndSwitch` in `web/src/components/ChatView.jsx`) sets the composer + value and preserves it across the session switch instead of only showing a + toast. +- Forking on an assistant message keeps today's behavior unchanged. +- Sending the refilled prompt unchanged stays allowed: the fork is a separate + session, so replaying the same prompt cannot affect the source session. + +## Capabilities + +### New Capabilities + +- `session-fork-anchor`: Defines how the retained history prefix is resolved for + a fork, including the user-message anchor rule and tool-call pairing safety. + +### Modified Capabilities + +None. + +## Impact + +- `src/session/session_rewind.cpp` / `session_rewind.hpp`: new anchor resolution + helper. +- `src/web/routes/fork_handler.cpp`: use the anchor and extend the response with + `restored_prompt` / `fork_anchor_role`. +- `web/src/components/ChatView.jsx`: refill composer in `forkAndSwitch` and set + the existing `preserveComposerInputOnSessionChangeRef` before switching. +- Tests in `tests/session/` and `tests/web/`. diff --git a/openspec/changes/fork-user-prompt-to-composer/specs/session-fork-anchor/spec.md b/openspec/changes/fork-user-prompt-to-composer/specs/session-fork-anchor/spec.md new file mode 100644 index 00000000..6e4f2a06 --- /dev/null +++ b/openspec/changes/fork-user-prompt-to-composer/specs/session-fork-anchor/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: Forking on a user message retains history from before that prompt +When the fork target is a user message, the new session SHALL retain the +conversation prefix that ends **before** that prompt ran, and SHALL NOT include +the clicked prompt as a committed message. + +#### Scenario: Fork on a user message that follows an assistant reply +- **WHEN** a client forks at a user message whose preceding real message is an assistant reply +- **THEN** the new session retains every message up to and including that assistant reply +- **AND** the clicked user message is not present in the new session history + +#### Scenario: Fork on the first user message of a session +- **WHEN** a client forks at a user message that has no real message before it +- **THEN** the new session is created with an empty conversation prefix +- **AND** the fork still succeeds + +#### Scenario: Fork on the later of two consecutive user messages +- **WHEN** a client forks at a user message whose preceding real message is another user message +- **THEN** the new session retains that earlier user message +- **AND** only the clicked user message is excluded + +### Requirement: Fork anchor resolution skips non-conversational records +The anchor scan SHALL skip records that are not part of the conversation, using +the same predicates the fork writer already uses, so no boundary or diagnostic +record can become the last message of a forked session. + +#### Scenario: Checkpoint, timing, and diff records sit between the prompt and the reply +- **WHEN** file checkpoint, turn timing, or turn net diff records appear between the previous assistant reply and the clicked user message +- **THEN** those records are skipped during anchor resolution +- **AND** the anchor lands on the assistant reply + +#### Scenario: Meta records are present +- **WHEN** a message is marked `is_meta` +- **THEN** it is skipped during anchor resolution + +### Requirement: A forked session never ends with a dangling tool call +The retained prefix SHALL always include the tool results for any assistant +message it retains that declares tool calls. + +#### Scenario: The clicked prompt interrupts a tool round +- **WHEN** the real message immediately before the clicked user message is a tool result +- **THEN** the anchor stops at that tool result +- **AND** the retained prefix still contains the assistant tool-call message together with its result + +#### Scenario: Anchor candidate is an assistant message with unsatisfied tool calls +- **WHEN** anchor resolution reaches an assistant message that declares tool calls whose results are not retained +- **THEN** resolution continues backwards past that assistant message + +### Requirement: Forking on an assistant message keeps existing behavior +When the fork target is not a user message, the new session SHALL retain the +prefix including that message, exactly as before this change. + +#### Scenario: Fork on an assistant reply +- **WHEN** a client forks at an assistant message +- **THEN** the retained prefix ends with that assistant message +- **AND** no prompt is returned for composer refill + +### Requirement: Fork response carries the prompt to restore +When the fork target is a user message, the fork response SHALL include the +plain text of that message and the role of the resolved anchor so the client can +refill the composer without re-deriving the rule. + +#### Scenario: Fork request targets a user message +- **WHEN** a fork succeeds on a user message +- **THEN** the response contains `restored_prompt` with that message's plain text +- **AND** the response contains `fork_anchor_role` + +#### Scenario: Fork request targets an assistant message +- **WHEN** a fork succeeds on an assistant message +- **THEN** the response contains no `restored_prompt` + +### Requirement: Client refills the composer with the restored prompt +After a fork that returns a prompt, the client SHALL place that text into the +composer, preserve it across the session switch, and leave it unsent. + +#### Scenario: Fork completes on desktop or Web +- **WHEN** a fork response contains `restored_prompt` +- **THEN** the composer value is set to that text before the new session is activated +- **AND** the composer value survives the session switch +- **AND** no message is sent automatically + +#### Scenario: User sends the refilled prompt unchanged +- **WHEN** the user submits the composer without editing the refilled text +- **THEN** the prompt is sent to the new session +- **AND** the source session is left untouched + +#### Scenario: Fork returns no prompt +- **WHEN** a fork response contains no `restored_prompt` +- **THEN** the composer is left as it was diff --git a/openspec/changes/fork-user-prompt-to-composer/tasks.md b/openspec/changes/fork-user-prompt-to-composer/tasks.md new file mode 100644 index 00000000..b92704b3 --- /dev/null +++ b/openspec/changes/fork-user-prompt-to-composer/tasks.md @@ -0,0 +1,26 @@ +## 1. Fork Anchor Resolution + +- [x] 1.1 Add `resolve_fork_anchor_index(messages, target_index)` to `src/session/session_rewind.{hpp,cpp}` returning the last index to retain. +- [x] 1.2 Reuse the existing non-conversational predicates (file checkpoint, turn timing, turn net diff, `is_meta`) instead of duplicating the conditions. +- [x] 1.3 Return no anchor when the target is the first real message, so the fork yields an empty prefix. +- [x] 1.4 Keep scanning past an assistant message whose tool calls have no retained results. + +## 2. Fork Handler + +- [x] 2.1 Replace `retained_prefix_before_index(messages, idx + 1)` in `src/web/routes/routes_sessions.cpp` with the resolved anchor. +- [x] 2.2 Keep the assistant-message path unchanged. +- [x] 2.3 Add `restored_prompt` and `fork_anchor_role` to the fork response. + +## 3. Client Composer Refill + +- [x] 3.1 In `forkAndSwitch` (`web/src/components/ChatView.jsx`), set `preserveComposerInputOnSessionChangeRef` and `setComposerValue` from `restored_prompt` before promoting the session. +- [x] 3.2 Adjust the toast so it tells the user the prompt was restored and can be edited. +- [x] 3.3 Leave the composer untouched when the response has no `restored_prompt`. + +## 4. Tests And Validation + +- [x] 4.1 Add unit tests in `tests/session/` covering: anchor after an assistant reply, first-message empty fork, consecutive user messages, skipped checkpoint/timing/diff records, and a tool result anchor. +- [x] 4.2 Add a fork handler test asserting `restored_prompt` for user targets and its absence for assistant targets. +- [x] 4.3 Add a Web test for the composer refill branch (`web/src/lib/sessionFork.{js,test.js}`, registered in `runTests.js`). +- [x] 4.4 Build and run the focused C++ tests, then the full suite and `scripts/code_quality_check.sh`. +- [x] 4.5 Run `pnpm test` and `pnpm build` in `web/`. diff --git a/src/session/session_rewind.cpp b/src/session/session_rewind.cpp index c17c1e18..1ff47ee7 100644 --- a/src/session/session_rewind.cpp +++ b/src/session/session_rewind.cpp @@ -1,5 +1,7 @@ #include "session_rewind.hpp" +#include "turn_net_diff.hpp" +#include "turn_timing.hpp" #include "../utils/uuid.hpp" #include @@ -49,6 +51,24 @@ std::string collapse_ws(std::string s) { return out; } +// Records that carry no conversation content. These are the same predicates +// SessionManager::fork_session_to_new_id filters out when writing the forked +// JSONL, so letting one become the last line of a fork would surface raw +// diagnostics as chat. +bool is_non_conversational_record(const ChatMessage& msg) { + if (msg.is_meta) return true; + if (is_file_checkpoint_message(msg)) return true; + if (is_turn_timing_message(msg)) return true; + if (is_turn_net_diff_message(msg)) return true; + return false; +} + +bool declares_tool_calls(const ChatMessage& msg) { + return msg.role == "assistant" && + msg.tool_calls.is_array() && + !msg.tool_calls.empty(); +} + } // namespace void ensure_user_message_identity(ChatMessage& msg) { @@ -92,6 +112,46 @@ std::vector retained_prefix_before_index( return std::vector(messages.begin(), messages.begin() + static_cast(end)); } +std::optional resolve_fork_anchor_index( + const std::vector& messages, + size_t target_index) { + if (target_index >= messages.size()) return std::nullopt; + + for (size_t i = target_index; i-- > 0;) { + const auto& msg = messages[i]; + if (is_non_conversational_record(msg)) continue; + // A tool result always follows its call, so an assistant message that + // declares calls has no results inside the retained prefix. Keep + // scanning so the fork never ends on an unanswered call. + if (declares_tool_calls(msg)) continue; + return i; + } + return std::nullopt; +} + +std::string fork_restored_prompt_text(const ChatMessage& msg) { + if (!msg.content.empty()) return msg.content; + // Multimodal input can leave `content` empty and carry the text only in + // `content_parts`. Join text parts with newlines; skip attachments. + if (!msg.content_parts.is_array()) return {}; + std::string text; + for (const auto& part : msg.content_parts) { + if (!part.is_object()) continue; + const auto type_it = part.find("type"); + if (type_it == part.end() || !type_it->is_string() || + type_it->get_ref() != "text") { + continue; + } + const auto text_it = part.find("text"); + if (text_it == part.end() || !text_it->is_string()) continue; + const std::string& part_text = text_it->get_ref(); + if (part_text.empty()) continue; + if (!text.empty()) text.push_back('\n'); + text += part_text; + } + return text; +} + std::string rewind_prefill_text(const ChatMessage& msg) { if (!is_rewind_selectable_user_message(msg)) return {}; return msg.content; diff --git a/src/session/session_rewind.hpp b/src/session/session_rewind.hpp index b5818c53..67dedeb9 100644 --- a/src/session/session_rewind.hpp +++ b/src/session/session_rewind.hpp @@ -3,6 +3,7 @@ #include "../provider/llm_provider.hpp" #include +#include #include #include @@ -32,6 +33,29 @@ std::vector retained_prefix_before_index( const std::vector& messages, size_t target_index); +// Index of the last message that a fork should keep when the user forks at +// `target_index`, or nullopt when nothing before the target should be kept. +// +// Scanning skips records that are not part of the conversation (meta messages, +// file checkpoints, turn timing, turn net diff) because keeping one as the last +// line of a forked session would expose diagnostics as if they were chat. +// +// It also refuses to stop on an assistant message that declares tool calls: +// the matching tool results always follow the call, so stopping there would +// leave a dangling call that providers reject. Stopping on a tool result is +// safe because the pair stays intact. +// +// `target_index` must be a valid index into `messages`. +std::optional resolve_fork_anchor_index( + const std::vector& messages, + size_t target_index); + +// Plain text of a user prompt that a fork response hands back for composer +// refill. Prefers `content`; when it is empty the text may live only in +// structured `content_parts` (multimodal input), so text parts are joined +// with newlines. Non-text parts (images, files) are not refilled. +std::string fork_restored_prompt_text(const ChatMessage& msg); + std::string rewind_prefill_text(const ChatMessage& msg); std::string rewind_preview_text(const ChatMessage& msg, size_t max_bytes = 80); diff --git a/src/web/routes/routes_sessions.cpp b/src/web/routes/routes_sessions.cpp index 799d1e85..5ea8a159 100644 --- a/src/web/routes/routes_sessions.cpp +++ b/src/web/routes/routes_sessions.cpp @@ -4,6 +4,7 @@ #include "../trajectory_legacy_projection.hpp" #include "../../session/compact_checkpoint.hpp" #include "../../session/global_session_catalog.hpp" +#include "../../session/session_rewind.hpp" #include "../../session/session_trajectory.hpp" #include "../../utils/utf8_path.hpp" @@ -2217,8 +2218,21 @@ void WebServer::Impl::register_sessions() { return with_cors(req, std::move(r)); } - // 含被点击的那条:retained = msgs[0..idx] - auto retained = retained_prefix_before_index(messages, *idx + 1); + // 点击 assistant 消息:沿用原行为,含被点击的那条。 + // 点击 user 提示词:回退到它之前的状态,提示词本身不进历史, + // 由前端回填输入框待用户修改后重发。 + const std::string target_role = messages[*idx].role; + std::string restored_prompt; + std::vector retained; + if (target_role == "user") { + restored_prompt = fork_restored_prompt_text(messages[*idx]); + const auto anchor = resolve_fork_anchor_index(messages, *idx); + retained = anchor.has_value() + ? retained_prefix_before_index(messages, *anchor + 1) + : std::vector{}; + } else { + retained = retained_prefix_before_index(messages, *idx + 1); + } // 组 source meta + sibling 列表用于命名规则 auto source_meta = entry->sm->load_session_meta(id); @@ -2266,6 +2280,10 @@ void WebServer::Impl::register_sessions() { resp["title"] = title; resp["forked_from"] = id; resp["fork_message_id"] = at_message_id; + resp["fork_anchor_role"] = target_role; + if (!restored_prompt.empty()) { + resp["restored_prompt"] = restored_prompt; + } resp["workspace_hash"] = entry->no_workspace ? std::string{} : entry->workspace_hash; resp["cwd"] = entry->no_workspace ? std::string{} : entry->cwd; resp["working_cwd"] = entry->cwd; diff --git a/tests/session/session_rewind_test.cpp b/tests/session/session_rewind_test.cpp index 1412422a..7e42e81f 100644 --- a/tests/session/session_rewind_test.cpp +++ b/tests/session/session_rewind_test.cpp @@ -2,6 +2,8 @@ #include "provider/llm_provider.hpp" #include "session/session_rewind.hpp" +#include "session/turn_net_diff.hpp" +#include "session/turn_timing.hpp" #include @@ -12,8 +14,10 @@ using acecode::ChatMessage; using acecode::collect_rewind_targets; using acecode::ensure_user_message_identity; +using acecode::fork_restored_prompt_text; using acecode::is_rewind_selectable_user_message; using acecode::retained_prefix_before_index; +using acecode::resolve_fork_anchor_index; using acecode::rewind_prefill_text; namespace { @@ -140,3 +144,164 @@ TEST(SessionRewind, TargetsAndPrefixAcrossToolMetaCompactAndShellPairs) { EXPECT_EQ(prefix[4].content, "!ls"); EXPECT_TRUE(prefix[6].is_compact_summary); } + +namespace { + +ChatMessage assistant(std::string content) { + ChatMessage msg; + msg.role = "assistant"; + msg.content = std::move(content); + return msg; +} + +ChatMessage tool_result(std::string content) { + ChatMessage msg; + msg.role = "tool"; + msg.content = std::move(content); + return msg; +} + +ChatMessage assistant_with_call() { + ChatMessage msg = assistant(""); + msg.tool_calls = nlohmann::json::array(); + msg.tool_calls.push_back(nlohmann::json{{"id", "call-1"}}); + return msg; +} + +ChatMessage checkpoint_record() { + ChatMessage msg; + msg.role = "system"; + msg.is_meta = true; + msg.subtype = "file_checkpoint"; + return msg; +} + +ChatMessage timing_record() { + acecode::TurnTimingRecord timing; + timing.user_message_uuid = "u1"; + timing.duration_ms = 10; + timing.status = "completed"; + return acecode::make_turn_timing_message(timing, "2026-09-08T00:00:00Z"); +} + +ChatMessage net_diff_record() { + acecode::TurnNetDiffRecord diff; + diff.user_message_uuid = "u1"; + return acecode::make_turn_net_diff_message(diff, "2026-09-08T00:00:00Z"); +} + +} // namespace + +TEST(SessionRewindForkAnchor, StopsAtTheAssistantReplyBeforeThePrompt) { + std::vector messages; + messages.push_back(user("first")); + messages.push_back(assistant("summary")); + messages.push_back(checkpoint_record()); + messages.push_back(user("second")); + + // 分叉点 index 3 是 user 提示词:跳过 file_checkpoint,停在 assistant 总结。 + const auto anchor = resolve_fork_anchor_index(messages, 3); + ASSERT_TRUE(anchor.has_value()); + EXPECT_EQ(*anchor, 1u); +} + +TEST(SessionRewindForkAnchor, SkipsTurnTimingAndNetDiffRecords) { + std::vector messages; + messages.push_back(user("first")); + messages.push_back(assistant("summary")); + messages.push_back(timing_record()); + messages.push_back(net_diff_record()); + messages.push_back(user("second")); + + ASSERT_TRUE(acecode::is_turn_timing_message(messages[2])); + ASSERT_TRUE(acecode::is_turn_net_diff_message(messages[3])); + + const auto anchor = resolve_fork_anchor_index(messages, 4); + ASSERT_TRUE(anchor.has_value()); + EXPECT_EQ(*anchor, 1u); +} + +TEST(SessionRewindForkAnchor, HasNoAnchorWhenThePromptIsTheFirstMessage) { + std::vector messages; + messages.push_back(user("only prompt")); + messages.push_back(assistant("reply")); + + // 第一条消息之前无内容可留,分叉结果为空会话。 + EXPECT_FALSE(resolve_fork_anchor_index(messages, 0).has_value()); +} + +TEST(SessionRewindForkAnchor, KeepsTheEarlierOfConsecutivePrompts) { + std::vector messages; + messages.push_back(user("first")); + messages.push_back(user("second")); + messages.push_back(user("third")); + + // 连续输入时只丢掉被点击的那条,前面的提问保留。 + const auto anchor = resolve_fork_anchor_index(messages, 2); + ASSERT_TRUE(anchor.has_value()); + EXPECT_EQ(*anchor, 1u); +} + +TEST(SessionRewindForkAnchor, StopsAtToolResultSoTheCallStaysAnswered) { + std::vector messages; + messages.push_back(assistant_with_call()); + messages.push_back(tool_result("output")); + messages.push_back(user("stop")); + + // tool 结果排在调用之后,停在结果处保留了完整配对; + // 若跳过结果去停在 assistant 上会留下悬空 tool_call。 + const auto anchor = resolve_fork_anchor_index(messages, 2); + ASSERT_TRUE(anchor.has_value()); + EXPECT_EQ(*anchor, 1u); +} + +TEST(SessionRewindForkAnchor, SkipsAssistantWithUnansweredToolCalls) { + std::vector messages; + messages.push_back(assistant("safe reply")); + messages.push_back(assistant_with_call()); + messages.push_back(user("stop")); + + // 该 assistant 的 tool 结果不存在,继续往前退,避免分叉出悬空调用。 + const auto anchor = resolve_fork_anchor_index(messages, 2); + ASSERT_TRUE(anchor.has_value()); + EXPECT_EQ(*anchor, 0u); +} + +TEST(SessionRewindForkAnchor, ReturnsNoAnchorForOutOfRangeTarget) { + std::vector messages; + messages.push_back(user("first")); + + EXPECT_FALSE(resolve_fork_anchor_index(messages, 1).has_value()); +} + +TEST(SessionRewindForkRestoredPrompt, PrefersContentWhenNonEmpty) { + ChatMessage msg = user("plain prompt"); + msg.content_parts = nlohmann::json::array({ + nlohmann::json{{"type", "text"}, {"text", "structured"}}, + }); + + // content 非空时直接使用,不看 content_parts。 + EXPECT_EQ(fork_restored_prompt_text(msg), "plain prompt"); +} + +TEST(SessionRewindForkRestoredPrompt, JoinsTextPartsWhenContentIsEmpty) { + ChatMessage msg = user(""); + msg.content_parts = nlohmann::json::array({ + nlohmann::json{{"type", "text"}, {"text", "line one"}}, + nlohmann::json{{"type", "image"}, {"source", "x.png"}}, + nlohmann::json{{"type", "text"}, {"text", "line two"}}, + }); + + // 文本只存于 content_parts 时拼接纯文本,附件跳过。 + EXPECT_EQ(fork_restored_prompt_text(msg), "line one\nline two"); +} + +TEST(SessionRewindForkRestoredPrompt, EmptyForAttachmentOnlyParts) { + ChatMessage msg = user(""); + msg.content_parts = nlohmann::json::array({ + nlohmann::json{{"type", "image"}, {"source", "x.png"}}, + }); + + // 只有附件没有文本时返回空串,前端因此跳过回填。 + EXPECT_EQ(fork_restored_prompt_text(msg), ""); +} diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index d4596584..4f047a44 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -53,6 +53,7 @@ #include "utils/text_file_buffer.hpp" #include "utils/utf8_path.hpp" #include "web/remote_web_proxy.hpp" +#include "web/message_payload.hpp" #include "web/server.hpp" #include "worktree/worktree_manager.hpp" @@ -3816,6 +3817,106 @@ TEST(WebServerHttp, ForkWorkspaceSessionResumesInSourceWorkspace) { EXPECT_TRUE(resumed->tool_capability_policy.builtin_tools->empty()); } +namespace { + +// 建一个 workspace + session,返回 session id。 +std::string create_workspace_session(WebServerFixture& fx, const std::string& cwd) { + const std::string hash = acecode::compute_cwd_hash(cwd); + auto post_ws = cpr::Post(cpr::Url{fx.url("/api/workspaces")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"cwd", cwd}}.dump()}); + if (post_ws.status_code != 201) return {}; + + auto create = cpr::Post(cpr::Url{fx.url("/api/workspaces/" + hash + "/sessions")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{R"({})"}); + if (create.status_code != 201) return {}; + return json::parse(create.text)["session_id"].get(); +} + +void append_message(acecode::SessionEntry* entry, + const std::string& role, + const std::string& content, + const std::string& uuid) { + acecode::ChatMessage msg; + msg.role = role; + msg.content = content; + msg.uuid = uuid; + entry->loop->push_message(msg); + entry->sm->on_message(msg); +} + +} // namespace + +// 场景: 在 user 提示词上分叉时,分叉点回退到该提示词之前,提示词本身不进 +// 新会话历史,而是随响应返回供前端回填输入框。 +TEST(WebServerHttp, ForkOnUserPromptRestoresPromptAndDropsItFromHistory) { + WebServerFixture fx; + + const std::string cwd = (fx.tmp_dir / "fork-prompt-cwd").string(); + std::filesystem::create_directories(cwd); + const std::string sid = create_workspace_session(fx, cwd); + ASSERT_FALSE(sid.empty()); + + auto* entry = fx.registry->lookup(sid); + ASSERT_NE(entry, nullptr); + append_message(entry, "user", "first prompt", "u1"); + append_message(entry, "assistant", "summary", "a1"); + append_message(entry, "user", "reword me", "u2"); + + auto fork = cpr::Post(cpr::Url{fx.url("/api/sessions/" + sid + "/fork")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"at_message_id", "u2"}}.dump()}); + ASSERT_EQ(fork.status_code, 200) << fork.text; + auto body = json::parse(fork.text); + + EXPECT_EQ(body["restored_prompt"], "reword me"); + EXPECT_EQ(body["fork_anchor_role"], "user"); + + auto* forked = fx.registry->lookup(body["session_id"].get()); + ASSERT_NE(forked, nullptr); + const auto msgs = forked->sm->load_active_messages(); + ASSERT_EQ(msgs.size(), 2u); + EXPECT_EQ(msgs[0].content, "first prompt"); + EXPECT_EQ(msgs[1].content, "summary"); +} + +// 场景: 在 assistant 消息上分叉保持原行为(含该条),且不返回待回填提示词。 +TEST(WebServerHttp, ForkOnAssistantMessageKeepsItWithoutRestoredPrompt) { + WebServerFixture fx; + + const std::string cwd = (fx.tmp_dir / "fork-assistant-cwd").string(); + std::filesystem::create_directories(cwd); + const std::string sid = create_workspace_session(fx, cwd); + ASSERT_FALSE(sid.empty()); + + auto* entry = fx.registry->lookup(sid); + ASSERT_NE(entry, nullptr); + append_message(entry, "user", "first prompt", "u1"); + append_message(entry, "assistant", "summary", "a1"); + + // assistant 消息的 id 是 sha1(role+content+timestamp),不是传入的 uuid, + // 必须从存储读回后按真实 id 分叉。 + const auto source_msgs = entry->sm->load_active_messages(); + ASSERT_EQ(source_msgs.size(), 2u); + const std::string assistant_id = acecode::web::compute_message_id(source_msgs[1]); + + auto fork = cpr::Post(cpr::Url{fx.url("/api/sessions/" + sid + "/fork")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{json{{"at_message_id", assistant_id}}.dump()}); + ASSERT_EQ(fork.status_code, 200) << fork.text; + auto body = json::parse(fork.text); + + EXPECT_FALSE(body.contains("restored_prompt")); + EXPECT_EQ(body["fork_anchor_role"], "assistant"); + + auto* forked = fx.registry->lookup(body["session_id"].get()); + ASSERT_NE(forked, nullptr); + const auto msgs = forked->sm->load_active_messages(); + ASSERT_EQ(msgs.size(), 2u); + EXPECT_EQ(msgs[1].content, "summary"); +} + // 场景: registry 里留着一个已删除/不可访问的 workspace 时,列表仍可返回, // 但不允许继续创建新会话,避免工具后续在坏 cwd 上才爆。 TEST(WebServerHttp, UnavailableWorkspaceRejectsSessionCreate) { diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index 42ca5793..f780d2a5 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -925,4 +925,6 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '导出': 'Export', '归档': 'Archive', '重命名': 'Rename', + '已分叉,提示词已回填输入框,可修改后重发': + 'Forked, prompt restored to the input box; edit it and resend', }); diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index 43eb6b04..000bc064 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -66,6 +66,7 @@ import { latestTurnSuccessfulChangedFiles, summarizeChangeGroups, } from '../lib/sessionChanges.js'; +import { forkRestoredPrompt } from '../lib/sessionFork.js'; import { stableBySignature } from '../lib/changeReviewStability.js'; import { acceptedQueuedInputEvent, @@ -3419,6 +3420,15 @@ export function ChatView({ sessionRef, sessionId, homeLogoEffectEnabled = true, created_at: r.created_at || now, updated_at: r.updated_at || now, }; + // 分叉点命中 user 提示词时,后端已把该提示词从历史中剔除并返回原文。 + // 这里回填输入框待用户修改后重发,不自动发送。 + // 切会话会重载 composer 草稿,必须先置 preserve 标记,否则回填会被清掉。 + const restoredPrompt = forkRestoredPrompt(r); + if (restoredPrompt) { + preserveComposerInputOnSessionChangeRef.current = true; + setComposerValue(restoredPrompt); + } + onSessionPromoted?.({ ...newSessionRefFrom(ref, r.session_id), title: r.title, @@ -3434,7 +3444,12 @@ export function ChatView({ sessionRef, sessionId, homeLogoEffectEnabled = true, noWorkspace, session: forkedSession, }); - toast({ kind: 'ok', text: '已分叉到 ' + (r.title || r.session_id) }); + toast({ + kind: 'ok', + text: restoredPrompt + ? '已分叉,提示词已回填输入框,可修改后重发' + : '已分叉到 ' + (r.title || r.session_id), + }); } catch (e) { toast({ kind: 'err', text: '分叉失败:' + (e?.message || '') }); } finally { diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index f977d7e4..6a2d3a5d 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -560,6 +560,7 @@ export const sourceCatalogs = { "s_5588a8ec809e249c": "可搜索或按 Tag 筛选,一次添加多位专家。", "s_55a139f14f573ec7": "继承所有全局可用项", "s_55a2ce1324c18355": "问题反馈", + "s_55d4ed5d1a18cdd8": "已分叉,提示词已回填输入框,可修改后重发", "s_55db57105231bd95": "工作空间不可用", "s_55ef676879f7cca3": "diff 过大,请在终端查看", "s_5653d97cdf569cb9": "系统防火墙、路由器或云安全组可能仍需放行代理端口。公网访问仍建议使用可信 VPN,并在上游配置 HTTPS。", @@ -2253,6 +2254,7 @@ export const sourceCatalogs = { "s_5588a8ec809e249c": "Search or filter by Tag to add multiple experts at once.", "s_55a139f14f573ec7": "Inherit all globally available items", "s_55a2ce1324c18355": "Feedback", + "s_55d4ed5d1a18cdd8": "Forked, prompt restored to the input box; edit it and resend", "s_55db57105231bd95": "Workspace is unavailable", "s_55ef676879f7cca3": "The diff is too large, please check it in the terminal", "s_5653d97cdf569cb9": "Your system firewall, router, or cloud security group may still need to allow the proxy port. For public Internet access, continue to use a trusted VPN and configure HTTPS upstream.", diff --git a/web/src/lib/runTests.js b/web/src/lib/runTests.js index 01e66ce7..fbe7beca 100644 --- a/web/src/lib/runTests.js +++ b/web/src/lib/runTests.js @@ -80,6 +80,7 @@ import './transcriptStreamIntegrity.test.js'; import './singleWriterOwnershipArchitecture.test.js'; import './previewRootArchitecture.test.js'; import './sessionChanges.test.js'; +import './sessionFork.test.js'; import './previewRefresh.test.js'; import './turnFileList.test.js'; import './gitSessionPill.test.js'; diff --git a/web/src/lib/sessionFork.js b/web/src/lib/sessionFork.js new file mode 100644 index 00000000..4a331158 --- /dev/null +++ b/web/src/lib/sessionFork.js @@ -0,0 +1,9 @@ +// 分叉响应里待回填输入框的提示词。 +// +// 后端只在分叉点命中 user 提示词时返回 `restored_prompt`:此时这条提示词 +// 已从新会话历史中剔除,需要回填让用户改后重发。分叉点命中 assistant 消息 +// 时没有该字段,输入框保持原样。空串视同没有。 +export function forkRestoredPrompt(response) { + const text = response?.restored_prompt; + return typeof text === 'string' && text ? text : ''; +} diff --git a/web/src/lib/sessionFork.test.js b/web/src/lib/sessionFork.test.js new file mode 100644 index 00000000..39168204 --- /dev/null +++ b/web/src/lib/sessionFork.test.js @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import { forkRestoredPrompt } from './sessionFork.js'; + +function run(name, fn) { + try { + fn(); + console.log(`[pass] ${name}`); + } catch (error) { + console.error(`[fail] ${name}`); + throw error; + } +} + +run('restores the prompt when the fork target was a user message', () => { + assert.equal(forkRestoredPrompt({ restored_prompt: 'reword me' }), 'reword me'); +}); + +run('restores nothing when the fork target was not a user message', () => { + assert.equal(forkRestoredPrompt({}), ''); + assert.equal(forkRestoredPrompt({ restored_prompt: '' }), ''); + assert.equal(forkRestoredPrompt({ restored_prompt: 42 }), ''); + assert.equal(forkRestoredPrompt(null), ''); + assert.equal(forkRestoredPrompt(undefined), ''); +}); From 402132f0b4dace6e2f67b773e4a7d845446153e5 Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 9 Sep 2026 08:01:31 +0800 Subject: [PATCH 2/3] chore: add macOS portable package script + skill with stale-frontend guards - scripts/macos_create_portable_zip.sh: builds a self-contained .app + TUI + share/acecode tree via cmake install; three guards prevent silently shipping stale embedded web UI (frontend newer than embedded assets -> auto reconfigure+rebuild; grep binary to confirm UI embedded). - macos-portable-package skill mirrored into .claude/.acecode/.agents/.codex skills/ so all agent toolchains can invoke it. --- .../skills/macos-portable-package/SKILL.md | 74 +++++++ .../skills/macos-portable-package/SKILL.md | 74 +++++++ .../skills/macos-portable-package/SKILL.md | 74 +++++++ .codex/skills/macos-portable-package/SKILL.md | 74 +++++++ scripts/macos_create_portable_zip.sh | 196 ++++++++++++++++++ 5 files changed, 492 insertions(+) create mode 100644 .acecode/skills/macos-portable-package/SKILL.md create mode 100644 .agents/skills/macos-portable-package/SKILL.md create mode 100644 .claude/skills/macos-portable-package/SKILL.md create mode 100644 .codex/skills/macos-portable-package/SKILL.md create mode 100644 scripts/macos_create_portable_zip.sh diff --git a/.acecode/skills/macos-portable-package/SKILL.md b/.acecode/skills/macos-portable-package/SKILL.md new file mode 100644 index 00000000..1ead55a6 --- /dev/null +++ b/.acecode/skills/macos-portable-package/SKILL.md @@ -0,0 +1,74 @@ +--- +name: macos-portable-package +description: Build an install-free ("green") macOS package for ACECode — a ditto zip containing ACECode.app, the `acecode` TUI binary, and the share/acecode resource tree. Use when asked to build a portable/green Mac package, a self-contained macOS zip, or to guard against the configure-time web-UI embed footgun (silent stale UI in the package). +platforms: [macos] +compatibility: ACECode build system +metadata: + tags: [packaging, macos, release, portable] +--- + +# macOS Portable ("Green") Package + +Produce a self-contained macOS zip that runs by double-click without installation. +Layout mirrors CI `.github/workflows/package.yml` "Package (Unix)" macOS branch: + +``` +ACECode.app (desktop shell, bundles acecode-daemon) +acecode (TUI executable) +README.md / README_CN.md +share/acecode/models_dev (api.json, MANIFEST.json, LICENSE) +share/acecode/seed (experts, skills, hooks) +``` + +## Usage + +```bash +bash scripts/macos_create_portable_zip.sh [suffix] [--arch arm64] [--output PATH] +# e.g. +bash scripts/macos_create_portable_zip.sh fork +bash scripts/macos_create_portable_zip.sh pre --arch arm64 +``` + +First positional arg is the output suffix (default `portable`). The build dir +defaults to `build/macos-x64-release`; the script auto-detects a working cmake +(see pitfalls) and a fresh `web/dist`. + +## The embed footgun this skill prevents + +Front-end assets are embedded into C++ **only at cmake configure time** +(`cmake/acecode_embed_assets.cmake` → `build/.../generated/static_assets_data.cpp`, +regenerated by `GLOB_RECURSE ... CONFIGURE_DEPENDS`). Running `cmake --build` +alone never refreshes them. If you edit `web/` and only rebuild, the zip silently +ships the old UI. The script enforces three guards: + +1. **Source newer than dist** (`web/src` mtime > `web/dist/index.html`) → fail + with "run pnpm build first". Catches the "edited UI, forgot to build" case. +2. **Dist newer than embedded assets** (`web/dist/index.html` mtime > the + generated cpp, OR a recorded dist-hash marker mismatch) → auto reconfigure + cmake (skipping vcpkg install) and rebuild `acecode-desktop`, then record the + new dist hash. Catches the "built UI, forgot to reconfigure" case. +3. **Post-build binary check** → after zipping, grep the extracted binary for a + dist-only token (`provider-logos`); abort if the embedded asset map is empty. + +## Pitfalls (verified on this machine) + +- **Broken cmake wrapper**: `~/.local/bin/cmake` is a broken pip shim that fails + with `assert cmake_files is not None`. The real binary is under the pip package: + `~/.local/lib/python3.12/site-packages/cmake/data/bin/cmake`. The script finds + any candidate that answers `--version`. +- **vcpkg install blocked**: reconfigure fails with + `remove("~/vcpkg/buildtrees/0.vcpkg_dep_info.cmake"): Operation not permitted` + (process-level, not a sandbox flag). Pass `-DVCPKG_MANIFEST_INSTALL=OFF` so + cmake skips vcpkg install — dependencies are already installed. +- **Verifying UI presence**: `strings` drops non-ASCII, so grepping a binary for + Chinese copy returns 0; minified JS also mangles symbol names. Use + `grep -a "provider-logos"` on the compiled binary instead. +- **arm64**: pass `--arch arm64`; the script threads `CMAKE_OSX_ARCHITECTURES` + and you must also use the `arm64-osx` vcpkg triplet/overlay if building native. +- **No GUI smoke test** under headless: `ACECode.app` cannot launch without a + display, but `./acecode --version` validates the TUI binary + dylibs. + +## Output + +`dist/acecode--macos---portable.zip` (~39 MB for 0.9.11), +plus a `du`/`shasum -a 256` line for the record. diff --git a/.agents/skills/macos-portable-package/SKILL.md b/.agents/skills/macos-portable-package/SKILL.md new file mode 100644 index 00000000..1ead55a6 --- /dev/null +++ b/.agents/skills/macos-portable-package/SKILL.md @@ -0,0 +1,74 @@ +--- +name: macos-portable-package +description: Build an install-free ("green") macOS package for ACECode — a ditto zip containing ACECode.app, the `acecode` TUI binary, and the share/acecode resource tree. Use when asked to build a portable/green Mac package, a self-contained macOS zip, or to guard against the configure-time web-UI embed footgun (silent stale UI in the package). +platforms: [macos] +compatibility: ACECode build system +metadata: + tags: [packaging, macos, release, portable] +--- + +# macOS Portable ("Green") Package + +Produce a self-contained macOS zip that runs by double-click without installation. +Layout mirrors CI `.github/workflows/package.yml` "Package (Unix)" macOS branch: + +``` +ACECode.app (desktop shell, bundles acecode-daemon) +acecode (TUI executable) +README.md / README_CN.md +share/acecode/models_dev (api.json, MANIFEST.json, LICENSE) +share/acecode/seed (experts, skills, hooks) +``` + +## Usage + +```bash +bash scripts/macos_create_portable_zip.sh [suffix] [--arch arm64] [--output PATH] +# e.g. +bash scripts/macos_create_portable_zip.sh fork +bash scripts/macos_create_portable_zip.sh pre --arch arm64 +``` + +First positional arg is the output suffix (default `portable`). The build dir +defaults to `build/macos-x64-release`; the script auto-detects a working cmake +(see pitfalls) and a fresh `web/dist`. + +## The embed footgun this skill prevents + +Front-end assets are embedded into C++ **only at cmake configure time** +(`cmake/acecode_embed_assets.cmake` → `build/.../generated/static_assets_data.cpp`, +regenerated by `GLOB_RECURSE ... CONFIGURE_DEPENDS`). Running `cmake --build` +alone never refreshes them. If you edit `web/` and only rebuild, the zip silently +ships the old UI. The script enforces three guards: + +1. **Source newer than dist** (`web/src` mtime > `web/dist/index.html`) → fail + with "run pnpm build first". Catches the "edited UI, forgot to build" case. +2. **Dist newer than embedded assets** (`web/dist/index.html` mtime > the + generated cpp, OR a recorded dist-hash marker mismatch) → auto reconfigure + cmake (skipping vcpkg install) and rebuild `acecode-desktop`, then record the + new dist hash. Catches the "built UI, forgot to reconfigure" case. +3. **Post-build binary check** → after zipping, grep the extracted binary for a + dist-only token (`provider-logos`); abort if the embedded asset map is empty. + +## Pitfalls (verified on this machine) + +- **Broken cmake wrapper**: `~/.local/bin/cmake` is a broken pip shim that fails + with `assert cmake_files is not None`. The real binary is under the pip package: + `~/.local/lib/python3.12/site-packages/cmake/data/bin/cmake`. The script finds + any candidate that answers `--version`. +- **vcpkg install blocked**: reconfigure fails with + `remove("~/vcpkg/buildtrees/0.vcpkg_dep_info.cmake"): Operation not permitted` + (process-level, not a sandbox flag). Pass `-DVCPKG_MANIFEST_INSTALL=OFF` so + cmake skips vcpkg install — dependencies are already installed. +- **Verifying UI presence**: `strings` drops non-ASCII, so grepping a binary for + Chinese copy returns 0; minified JS also mangles symbol names. Use + `grep -a "provider-logos"` on the compiled binary instead. +- **arm64**: pass `--arch arm64`; the script threads `CMAKE_OSX_ARCHITECTURES` + and you must also use the `arm64-osx` vcpkg triplet/overlay if building native. +- **No GUI smoke test** under headless: `ACECode.app` cannot launch without a + display, but `./acecode --version` validates the TUI binary + dylibs. + +## Output + +`dist/acecode--macos---portable.zip` (~39 MB for 0.9.11), +plus a `du`/`shasum -a 256` line for the record. diff --git a/.claude/skills/macos-portable-package/SKILL.md b/.claude/skills/macos-portable-package/SKILL.md new file mode 100644 index 00000000..1ead55a6 --- /dev/null +++ b/.claude/skills/macos-portable-package/SKILL.md @@ -0,0 +1,74 @@ +--- +name: macos-portable-package +description: Build an install-free ("green") macOS package for ACECode — a ditto zip containing ACECode.app, the `acecode` TUI binary, and the share/acecode resource tree. Use when asked to build a portable/green Mac package, a self-contained macOS zip, or to guard against the configure-time web-UI embed footgun (silent stale UI in the package). +platforms: [macos] +compatibility: ACECode build system +metadata: + tags: [packaging, macos, release, portable] +--- + +# macOS Portable ("Green") Package + +Produce a self-contained macOS zip that runs by double-click without installation. +Layout mirrors CI `.github/workflows/package.yml` "Package (Unix)" macOS branch: + +``` +ACECode.app (desktop shell, bundles acecode-daemon) +acecode (TUI executable) +README.md / README_CN.md +share/acecode/models_dev (api.json, MANIFEST.json, LICENSE) +share/acecode/seed (experts, skills, hooks) +``` + +## Usage + +```bash +bash scripts/macos_create_portable_zip.sh [suffix] [--arch arm64] [--output PATH] +# e.g. +bash scripts/macos_create_portable_zip.sh fork +bash scripts/macos_create_portable_zip.sh pre --arch arm64 +``` + +First positional arg is the output suffix (default `portable`). The build dir +defaults to `build/macos-x64-release`; the script auto-detects a working cmake +(see pitfalls) and a fresh `web/dist`. + +## The embed footgun this skill prevents + +Front-end assets are embedded into C++ **only at cmake configure time** +(`cmake/acecode_embed_assets.cmake` → `build/.../generated/static_assets_data.cpp`, +regenerated by `GLOB_RECURSE ... CONFIGURE_DEPENDS`). Running `cmake --build` +alone never refreshes them. If you edit `web/` and only rebuild, the zip silently +ships the old UI. The script enforces three guards: + +1. **Source newer than dist** (`web/src` mtime > `web/dist/index.html`) → fail + with "run pnpm build first". Catches the "edited UI, forgot to build" case. +2. **Dist newer than embedded assets** (`web/dist/index.html` mtime > the + generated cpp, OR a recorded dist-hash marker mismatch) → auto reconfigure + cmake (skipping vcpkg install) and rebuild `acecode-desktop`, then record the + new dist hash. Catches the "built UI, forgot to reconfigure" case. +3. **Post-build binary check** → after zipping, grep the extracted binary for a + dist-only token (`provider-logos`); abort if the embedded asset map is empty. + +## Pitfalls (verified on this machine) + +- **Broken cmake wrapper**: `~/.local/bin/cmake` is a broken pip shim that fails + with `assert cmake_files is not None`. The real binary is under the pip package: + `~/.local/lib/python3.12/site-packages/cmake/data/bin/cmake`. The script finds + any candidate that answers `--version`. +- **vcpkg install blocked**: reconfigure fails with + `remove("~/vcpkg/buildtrees/0.vcpkg_dep_info.cmake"): Operation not permitted` + (process-level, not a sandbox flag). Pass `-DVCPKG_MANIFEST_INSTALL=OFF` so + cmake skips vcpkg install — dependencies are already installed. +- **Verifying UI presence**: `strings` drops non-ASCII, so grepping a binary for + Chinese copy returns 0; minified JS also mangles symbol names. Use + `grep -a "provider-logos"` on the compiled binary instead. +- **arm64**: pass `--arch arm64`; the script threads `CMAKE_OSX_ARCHITECTURES` + and you must also use the `arm64-osx` vcpkg triplet/overlay if building native. +- **No GUI smoke test** under headless: `ACECode.app` cannot launch without a + display, but `./acecode --version` validates the TUI binary + dylibs. + +## Output + +`dist/acecode--macos---portable.zip` (~39 MB for 0.9.11), +plus a `du`/`shasum -a 256` line for the record. diff --git a/.codex/skills/macos-portable-package/SKILL.md b/.codex/skills/macos-portable-package/SKILL.md new file mode 100644 index 00000000..1ead55a6 --- /dev/null +++ b/.codex/skills/macos-portable-package/SKILL.md @@ -0,0 +1,74 @@ +--- +name: macos-portable-package +description: Build an install-free ("green") macOS package for ACECode — a ditto zip containing ACECode.app, the `acecode` TUI binary, and the share/acecode resource tree. Use when asked to build a portable/green Mac package, a self-contained macOS zip, or to guard against the configure-time web-UI embed footgun (silent stale UI in the package). +platforms: [macos] +compatibility: ACECode build system +metadata: + tags: [packaging, macos, release, portable] +--- + +# macOS Portable ("Green") Package + +Produce a self-contained macOS zip that runs by double-click without installation. +Layout mirrors CI `.github/workflows/package.yml` "Package (Unix)" macOS branch: + +``` +ACECode.app (desktop shell, bundles acecode-daemon) +acecode (TUI executable) +README.md / README_CN.md +share/acecode/models_dev (api.json, MANIFEST.json, LICENSE) +share/acecode/seed (experts, skills, hooks) +``` + +## Usage + +```bash +bash scripts/macos_create_portable_zip.sh [suffix] [--arch arm64] [--output PATH] +# e.g. +bash scripts/macos_create_portable_zip.sh fork +bash scripts/macos_create_portable_zip.sh pre --arch arm64 +``` + +First positional arg is the output suffix (default `portable`). The build dir +defaults to `build/macos-x64-release`; the script auto-detects a working cmake +(see pitfalls) and a fresh `web/dist`. + +## The embed footgun this skill prevents + +Front-end assets are embedded into C++ **only at cmake configure time** +(`cmake/acecode_embed_assets.cmake` → `build/.../generated/static_assets_data.cpp`, +regenerated by `GLOB_RECURSE ... CONFIGURE_DEPENDS`). Running `cmake --build` +alone never refreshes them. If you edit `web/` and only rebuild, the zip silently +ships the old UI. The script enforces three guards: + +1. **Source newer than dist** (`web/src` mtime > `web/dist/index.html`) → fail + with "run pnpm build first". Catches the "edited UI, forgot to build" case. +2. **Dist newer than embedded assets** (`web/dist/index.html` mtime > the + generated cpp, OR a recorded dist-hash marker mismatch) → auto reconfigure + cmake (skipping vcpkg install) and rebuild `acecode-desktop`, then record the + new dist hash. Catches the "built UI, forgot to reconfigure" case. +3. **Post-build binary check** → after zipping, grep the extracted binary for a + dist-only token (`provider-logos`); abort if the embedded asset map is empty. + +## Pitfalls (verified on this machine) + +- **Broken cmake wrapper**: `~/.local/bin/cmake` is a broken pip shim that fails + with `assert cmake_files is not None`. The real binary is under the pip package: + `~/.local/lib/python3.12/site-packages/cmake/data/bin/cmake`. The script finds + any candidate that answers `--version`. +- **vcpkg install blocked**: reconfigure fails with + `remove("~/vcpkg/buildtrees/0.vcpkg_dep_info.cmake"): Operation not permitted` + (process-level, not a sandbox flag). Pass `-DVCPKG_MANIFEST_INSTALL=OFF` so + cmake skips vcpkg install — dependencies are already installed. +- **Verifying UI presence**: `strings` drops non-ASCII, so grepping a binary for + Chinese copy returns 0; minified JS also mangles symbol names. Use + `grep -a "provider-logos"` on the compiled binary instead. +- **arm64**: pass `--arch arm64`; the script threads `CMAKE_OSX_ARCHITECTURES` + and you must also use the `arm64-osx` vcpkg triplet/overlay if building native. +- **No GUI smoke test** under headless: `ACECode.app` cannot launch without a + display, but `./acecode --version` validates the TUI binary + dylibs. + +## Output + +`dist/acecode--macos---portable.zip` (~39 MB for 0.9.11), +plus a `du`/`shasum -a 256` line for the record. diff --git a/scripts/macos_create_portable_zip.sh b/scripts/macos_create_portable_zip.sh new file mode 100644 index 00000000..fe20983c --- /dev/null +++ b/scripts/macos_create_portable_zip.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# Build a macOS "green"/portable (install-free) package for ACECode. +# +# Layout mirrors CI `.github/workflows/package.yml` "Package (Unix)" macOS branch: +# acecode (TUI) + README.md + README_CN.md + share/acecode/{models_dev,seed} + ACECode.app +# Zipped with ditto (keeps macOS metadata + symlinks, safe for .app). +# +# WHY THIS SCRIPT EXISTS (a real footgun): +# Front-end assets are embedded into C++ only at *cmake configure* time +# (cmake/acecode_embed_assets.cmake -> build/.../generated/static_assets_data.cpp). +# Running `cmake --build` alone WILL NOT refresh them. If you edit web/ and just +# rebuild, the zip ships yesterday's UI silently. This script guards against that. +set -euo pipefail + +repo_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +build_dir="$repo_root/build/macos-x64-release" +arch="x86_64" +suffix="portable" +output_path="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --build-dir) build_dir="${2:-}"; shift 2 ;; + --arch) arch="${2:-}"; shift 2 ;; + --suffix) suffix="${2:-}"; shift 2 ;; + --output) output_path="${2:-}"; shift 2 ;; + --help|-h) sed -n '2,20p' "${BASH_SOURCE[0]}"; exit 0 ;; + -*) echo "Unknown option: $1" >&2; exit 2 ;; + *) suffix="$1"; shift ;; # first positional arg = suffix + esac +done + +version="$(grep -m1 'project(acecode VERSION' "$repo_root/CMakeLists.txt" | sed -E 's/.* VERSION ([0-9.]+).*/\1/')" +dist_html="$repo_root/web/dist/index.html" +embed_cpp="$build_dir/generated/static_assets_data.cpp" +embed_marker="$build_dir/generated/embedded_web_dist.sha" +package_name="acecode-macos-$arch" +package_dir="$repo_root/dist/$package_name" +[[ -z "$output_path" ]] && output_path="$repo_root/dist/acecode-${version}-macos-${arch}-${suffix}-portable.zip" + +echo "== version : $version" +echo "== branch : $(git -C "$repo_root" rev-parse --abbrev-ref HEAD) @ $(git -C "$repo_root" rev-parse --short HEAD)" +echo "== build : $build_dir" + +# Locate a working cmake. The wrapper at ~/.local/bin/cmake can be a broken +# pip shim; the real binary lives under the cmake package's data/bin/. Prefer +# any candidate that answers --version. +CMAKE_BIN="" +for cand in \ + "$(find "$HOME/.local/lib" -path '*/cmake/data/bin/cmake' -type f 2>/dev/null | head -1)" \ + "$(command -v cmake 2>/dev/null)"; do + [[ -n "$cand" && -x "$cand" ]] || continue + if "$cand" --version >/dev/null 2>&1; then CMAKE_BIN="$cand"; break; fi +done +[[ -n "$CMAKE_BIN" ]] || { echo "ERROR: cmake not found" >&2; exit 1; } +echo "== cmake : $CMAKE_BIN" + +# --------------------------------------------------------------------------- +# GUARD 1: front-end source newer than build output? -> forgot `pnpm build` +# --------------------------------------------------------------------------- +if [[ ! -f "$dist_html" ]]; then + echo "ERROR: web/dist/index.html missing — run 'cd web && pnpm install && pnpm build' first." >&2 + exit 1 +fi +newest_src="$(find "$repo_root/web/src" -type f -newer "$dist_html" 2>/dev/null | head -1 || true)" +if [[ -n "$newest_src" ]]; then + echo "ERROR: front-end source is newer than web/dist (e.g. $newest_src)." >&2 + echo " You edited web/ but did not rebuild. Run:" >&2 + echo " cd web && pnpm install && pnpm build" >&2 + exit 1 +fi + +# --------------------------------------------------------------------------- +# GUARD 2: dist newer than embedded assets? -> forgot to reconfigure cmake. +# Decision is made by BOTH mtime AND a recorded dist hash marker (mtime alone +# can lie after a git checkout/reset rewrites the tree). +# --------------------------------------------------------------------------- +needs_reconfigure=false +if [[ ! -f "$embed_cpp" ]]; then + needs_reconfigure=true +elif [[ "$dist_html" -nt "$embed_cpp" ]]; then + needs_reconfigure=true +fi +current_hash="$(shasum -a 256 "$dist_html" | awk '{print $1}')" +if [[ -f "$embed_marker" ]]; then + embedded_hash="$(cat "$embed_marker" 2>/dev/null || true)" + [[ "$embedded_hash" != "$current_hash" ]] && needs_reconfigure=true +else + needs_reconfigure=true +fi + +if $needs_reconfigure; then + echo "== web/dist changed since last embed — reconfiguring cmake (skipping vcpkg install) ==" + "$CMAKE_BIN" -S "$repo_root" -B "$build_dir" -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_TOOLCHAIN_FILE="$HOME/vcpkg/scripts/buildsystems/vcpkg.cmake" \ + -DVCPKG_TARGET_TRIPLET=x64-osx \ + -DVCPKG_OVERLAY_PORTS="$repo_root/ports" \ + -DCMAKE_OSX_ARCHITECTURES="$arch" \ + -DBUILD_TESTING=OFF -DACECODE_BUILD_DESKTOP=ON \ + -DVCPKG_MANIFEST_FEATURES=tests -DVCPKG_MANIFEST_INSTALL=OFF + echo "== rebuilding acecode-desktop with fresh embedded assets ==" + "$CMAKE_BIN" --build "$build_dir" --target acecode-desktop + echo "$current_hash" > "$embed_marker" +else + echo "== embedded assets already fresh (dist hash matches $embed_marker) ==" +fi + +# --------------------------------------------------------------------------- +# Assemble portable layout +# --------------------------------------------------------------------------- +validate_models_dev_registry() { + local registry_dir="$1" + local file_count + file_count="$(find "$registry_dir" -type f | wc -l | tr -d '[:space:]')" + if [[ "$file_count" != "3" ]]; then + echo "Expected exactly 3 models.dev files in $registry_dir, found $file_count" >&2 + exit 1 + fi + for f in api.json MANIFEST.json LICENSE; do + if [[ ! -f "$registry_dir/$f" ]] || + ! cmp -s "$repo_root/assets/models_dev/$f" "$registry_dir/$f"; then + echo "Missing or mismatched models.dev file: $registry_dir/$f" >&2 + exit 1 + fi + done +} + +echo "== assembling $package_dir ==" +if [[ -d "$package_dir" ]]; then + mv "$package_dir" "/tmp/portable-pkg-$(date +%s)" +fi +mkdir -p "$package_dir" + +if [[ ! -f "$build_dir/acecode" ]]; then + echo "ERROR: terminal executable missing: $build_dir/acecode (run the build above again)" >&2 + exit 1 +fi +cp "$build_dir/acecode" "$package_dir/" +cp README.md README_CN.md "$package_dir/" + +"$CMAKE_BIN" --install "$build_dir" --prefix "$package_dir" --component models_dev_registry >/dev/null +"$CMAKE_BIN" --install "$build_dir" --prefix "$package_dir" --component default_seed_bundle >/dev/null +validate_models_dev_registry "$package_dir/share/acecode/models_dev" +python3 "$repo_root/scripts/verify_seed_bundle.py" \ + --source "$repo_root/assets/seed" \ + --packaged "$package_dir/share/acecode/seed" + +if [[ ! -d "$build_dir/ACECode.app" ]]; then + echo "ERROR: desktop app bundle missing: $build_dir/ACECode.app" >&2 + exit 1 +fi +cp -R "$build_dir/ACECode.app" "$package_dir/" + +legacy="$(find "$package_dir" -maxdepth 1 -iname 'ace-browser-*' -print -quit)" +if [[ -n "$legacy" ]]; then + echo "ERROR: legacy browser artifact must not be packaged: $legacy" >&2 + exit 1 +fi + +echo "== zipping with ditto ==" +rm -f "$output_path" +( cd "$repo_root/dist" && /usr/bin/ditto -c -k --keepParent --sequesterRsrc \ + "$package_name" "$(basename "$output_path")" ) + +# --------------------------------------------------------------------------- +# GUARD 3: verify the freshly built binary actually contains embedded UI. +# `strings` drops non-ASCII, so grep the binary directly for a dist-only token. +# --------------------------------------------------------------------------- +echo "== verifying archive ==" +verify_root="$(mktemp -d)" +trap 'rm -rf -- "$verify_root"' EXIT +/usr/bin/ditto -x -k "$output_path" "$verify_root" +extracted="$verify_root/$package_name" + +[[ -x "$extracted/acecode" ]] || { echo "extracted acecode not executable" >&2; exit 1; } +[[ -x "$extracted/ACECode.app/Contents/MacOS/ACECode" ]] || { echo "extracted app main missing" >&2; exit 1; } +[[ -x "$extracted/ACECode.app/Contents/MacOS/acecode-daemon" ]] || { echo "extracted daemon missing" >&2; exit 1; } +if ! grep -aq "provider-logos" "$extracted/acecode"; then + echo "ERROR: built binary does NOT contain embedded web UI (provider-logos absent)." >&2 + echo " The embed step silently fell back to an empty asset map. Reconfigure cmake." >&2 + exit 1 +fi +validate_models_dev_registry "$extracted/share/acecode/models_dev" +validate_models_dev_registry "$extracted/ACECode.app/Contents/Resources/share/acecode/models_dev" +python3 "$repo_root/scripts/verify_seed_bundle.py" \ + --source "$repo_root/assets/seed" --packaged "$extracted/share/acecode/seed" +python3 "$repo_root/scripts/verify_seed_bundle.py" \ + --source "$repo_root/assets/seed" \ + --packaged "$extracted/ACECode.app/Contents/Resources/share/acecode/seed" + +echo "== done ==" +du -sh "$package_dir" | cat +ls -lh "$output_path" | cat +shasum -a 256 "$output_path" | cat +echo "OK: $output_path" From 552c97414dcdbc19fe9e50630dec4f89f7d54ed0 Mon Sep 17 00:00:00 2001 From: slyxyllt Date: Wed, 9 Sep 2026 14:07:30 +0800 Subject: [PATCH 3/3] =?UTF-8?q?refine:=20simplify=20fork=20toast=20copy=20?= =?UTF-8?q?to=20=E5=B7=B2=E5=88=9B=E5=BB=BA=E5=88=86=E6=94=AF=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shortens the restored-prompt toast from 已分叉,提示词已回填输入框,可修改后重发 to just 已创建分支会话; the composer refill behaviour is unchanged. Registers the new copy in the i18n source catalog (zh-CN + en-US). --- web/scripts/i18n-en-overrides.mjs | 3 +-- web/src/components/ChatView.jsx | 2 +- web/src/i18n/sourceCatalog.generated.js | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/web/scripts/i18n-en-overrides.mjs b/web/scripts/i18n-en-overrides.mjs index f780d2a5..c6cdf6bc 100644 --- a/web/scripts/i18n-en-overrides.mjs +++ b/web/scripts/i18n-en-overrides.mjs @@ -925,6 +925,5 @@ export const ENGLISH_SOURCE_OVERRIDES = Object.freeze({ '导出': 'Export', '归档': 'Archive', '重命名': 'Rename', - '已分叉,提示词已回填输入框,可修改后重发': - 'Forked, prompt restored to the input box; edit it and resend', + '已创建分支会话': 'Forked session created', }); diff --git a/web/src/components/ChatView.jsx b/web/src/components/ChatView.jsx index 000bc064..f290a94c 100644 --- a/web/src/components/ChatView.jsx +++ b/web/src/components/ChatView.jsx @@ -3447,7 +3447,7 @@ export function ChatView({ sessionRef, sessionId, homeLogoEffectEnabled = true, toast({ kind: 'ok', text: restoredPrompt - ? '已分叉,提示词已回填输入框,可修改后重发' + ? '已创建分支会话' : '已分叉到 ' + (r.title || r.session_id), }); } catch (e) { diff --git a/web/src/i18n/sourceCatalog.generated.js b/web/src/i18n/sourceCatalog.generated.js index 6a2d3a5d..765e6522 100644 --- a/web/src/i18n/sourceCatalog.generated.js +++ b/web/src/i18n/sourceCatalog.generated.js @@ -560,7 +560,6 @@ export const sourceCatalogs = { "s_5588a8ec809e249c": "可搜索或按 Tag 筛选,一次添加多位专家。", "s_55a139f14f573ec7": "继承所有全局可用项", "s_55a2ce1324c18355": "问题反馈", - "s_55d4ed5d1a18cdd8": "已分叉,提示词已回填输入框,可修改后重发", "s_55db57105231bd95": "工作空间不可用", "s_55ef676879f7cca3": "diff 过大,请在终端查看", "s_5653d97cdf569cb9": "系统防火墙、路由器或云安全组可能仍需放行代理端口。公网访问仍建议使用可信 VPN,并在上游配置 HTTPS。", @@ -1274,6 +1273,7 @@ export const sourceCatalogs = { "s_c0f199171e53c7b1": "查看探测结果", "s_c1d514e0ffe641b8": "定时任务,归档", "s_c1e371336945b78e": "bash / npm / git 等 Agent 会逐步确认", + "s_c1f563ebdb29c100": "已创建分支会话", "s_c2144b0b3814435e": "个可用", "s_c23f7ae7d246d7e4": "没有可导入的 opencode 会话", "s_c2a497830ef74215": "最新版本", @@ -2254,7 +2254,6 @@ export const sourceCatalogs = { "s_5588a8ec809e249c": "Search or filter by Tag to add multiple experts at once.", "s_55a139f14f573ec7": "Inherit all globally available items", "s_55a2ce1324c18355": "Feedback", - "s_55d4ed5d1a18cdd8": "Forked, prompt restored to the input box; edit it and resend", "s_55db57105231bd95": "Workspace is unavailable", "s_55ef676879f7cca3": "The diff is too large, please check it in the terminal", "s_5653d97cdf569cb9": "Your system firewall, router, or cloud security group may still need to allow the proxy port. For public Internet access, continue to use a trusted VPN and configure HTTPS upstream.", @@ -2968,6 +2967,7 @@ export const sourceCatalogs = { "s_c0f199171e53c7b1": "View detection result", "s_c1d514e0ffe641b8": "Scheduled task, archive", "s_c1e371336945b78e": "Agent will request confirmation for bash, npm, git, and more", + "s_c1f563ebdb29c100": "Forked session created", "s_c2144b0b3814435e": "available", "s_c23f7ae7d246d7e4": "No opencode sessions to import", "s_c2a497830ef74215": "latest version",