Compare commits

...

4 Commits

Author SHA1 Message Date
Patrick Buckley 9a996f0067 release: v0.9.3
- fix: orphaned tool_use followup — ordering, empty IDs, provider_content bypass, universal repair (#220)
- feat: /retry and /rewind commands, message action controls in web UI (#221)
- docs: update tools, architecture, SDK for v0.9.2 changes
2026-03-29 14:19:02 -07:00
Patrick Buckley a465ac6383 Feat/rewind retry (#221)
* feat: add /retry and /rewind commands for conversation history navigation

Allow users to re-send the last message for a new response (/retry) or
drop the last N turns to restore an earlier conversation state (/rewind N).
Both operations sync in-memory state with the persistent database.

Server path includes conversation.modify permission gate, audit trail
(conversation.rewind / conversation.retry events), and thread-safe retry
dispatch. Migration 029 grants the permission to admin and operator roles.

* feat: add message action controls for retry, edit, and rewind in web UI

Hover toolbar on messages with CSS-only icons matching instrument panel
aesthetic. User messages get edit (pencil) and rewind (chevrons) buttons;
last assistant message gets retry (circular arrow). Edit flow uses
event-driven coordination — rewind completes via SSE history event before
send fires. Includes ARIA labels, keyboard nav, touch device support,
reduced motion, and busy-state gating.
2026-03-29 14:18:39 -07:00
Patrick Buckley a4539923e4 fix: orphaned tool_use followup — ordering, empty IDs, universal repair (#220)
Addresses Copilot review feedback on #219:

1. Anthropic _convert_messages: collect tool_use IDs in order (list
   not set), filter empty IDs, defer synthetic results until after
   real tool results so _merge_consecutive produces correct ordering.

2. Universal repair in reconstruct_messages: synthesize tool results
   for mid-conversation orphaned tool calls on DB load. Benefits all
   providers (OpenAI is lenient today but may tighten).

3. Test improvements: assert on is_error flag instead of "cancelled"
   substring, verify real-before-synthetic ordering in partial results.
2026-03-29 13:33:58 -07:00
Patrick Buckley 42e99d6990 docs: update tools, architecture, SDK for v0.9.2 changes
- docs/tools.md: batch edit_file (edits array), bash stderr prefix,
  math sandbox extras, output truncation
- docs/judge.md: JSON secret detection in output guard
- docs/architecture.md: state_change now sent to per-workstream SSE
- README.md: [sandbox] extras group in requirements
- TypeScript SDK: StateChangeEvent type, type guard, exports
- OpenAPI specs regenerated
2026-03-29 06:06:30 -07:00
27 changed files with 1392 additions and 34 deletions
+1
View File
@@ -415,6 +415,7 @@ Per-workstream metrics are labeled by `ws_id` (bounded by `[server].max_workstre
- Redis (for message queue bridge — `pip install turnstone[mq]`)
- Anthropic provider (optional — `pip install turnstone[anthropic]`)
- PostgreSQL (optional, for production — `pip install turnstone[postgres]`)
- Math sandbox packages (optional — `pip install turnstone[sandbox]` for sympy, numpy, scipy, pytest)
- [Git LFS](https://git-lfs.com/) (for cloning — diagram PNGs are stored in LFS)
## License
+1 -1
View File
@@ -259,7 +259,7 @@ class SessionUI(Protocol):
| Class | Module | Notes |
|-------|--------|-------|
| `TerminalUI` | `turnstone.cli` | ANSI colors, `MarkdownRenderer`, `Spinner`, readline-based `input()` for approval |
| `WebUI` | `turnstone.server` | SSE event queue per workstream, `threading.Event` for blocking on approval/plan |
| `WebUI` | `turnstone.server` | SSE event queue per workstream + global broadcast, `threading.Event` for blocking on approval/plan. `on_state_change` sends to both per-workstream and global SSE (the browser UI uses per-workstream `state_change` events to manage busy/idle transitions; `stream_end` only finalizes markdown rendering). |
| `NullUI` | `turnstone.eval` | Discards all output; `approve_tools` always returns `(True, None)` |
### WorkstreamTerminalUI
+1 -1
View File
@@ -338,7 +338,7 @@ from the output before it enters the conversation.
| Priority | Category | Risk | Examples |
|----------|----------|------|----------|
| 1 | Prompt injection | high | Override phrases, role injection (`{"role":"system"}`), instruction override markers |
| 2 | Credential leakage | high | API keys, private key blocks, connection strings, `.env` format secrets |
| 2 | Credential leakage | high | API keys, private key blocks, connection strings, `.env` format secrets, JSON secrets (`"api_key": "..."`, `"password": "..."`, etc.) |
| 3 | Encoded payloads | medium | Script data URIs, hex shellcode sequences |
| 4 | Adversarial URLs | medium | Cloud metadata endpoints, credential-bearing query parameters |
| 5 | System info disclosure | low | Private IP addresses, sensitive file paths |
+12 -6
View File
@@ -189,7 +189,8 @@ Execute a bash command and return stdout + stderr.
|-----------|--------|----------|-------------|
| `command` | string | yes | The bash command to execute. |
- **What it does**: Runs the command in a subprocess with a configurable timeout. Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`).
- **What it does**: Runs the command in a subprocess with a configurable timeout (default 120s). Commands are sanitized and checked against a blocklist (e.g. `rm -rf /`). Environment variables containing secrets are scrubbed (`*_KEY`, `*_SECRET`, `*_TOKEN`, etc.).
- **Output format**: Stdout is returned directly. Stderr lines are prefixed with `[stderr]` so the model can distinguish them. When the command itself redirects stderr to stdout (`2>&1`), no prefix is added. Output exceeding 256KB is truncated (head + tail preserved, middle replaced with a truncation notice).
- **Auto-approve**: No -- requires user confirmation.
- **Agent availability**: `task_agent` only (not available to plan sub-agents).
@@ -230,16 +231,20 @@ Write content to a file, creating it if needed.
### edit_file
Replace an exact string in a file with new content.
Replace exact strings in a file, or apply multiple replacements atomically.
| Parameter | Type | Required | Description |
|--------------|---------|----------|-------------|
| `path` | string | yes | Absolute or relative file path. |
| `old_string` | string | yes | The exact text to find and replace. |
| `new_string` | string | yes | The replacement text. |
| `old_string` | string | no* | The exact text to find and replace. |
| `new_string` | string | no* | The replacement text. |
| `near_line` | integer | no | Disambiguate when `old_string` matches multiple locations. |
| `edits` | array | no* | Multiple replacements to apply atomically (see below). |
\* Provide either `old_string`+`new_string` (single edit) or `edits` array (batch), not both.
- **What it does**: Finds `old_string` in the file and replaces it with `new_string`. Fails if the string is not found or matches multiple locations (unless `near_line` is provided to pick the nearest match). Requires a prior `read_file` call on the same path.
- **Batch mode**: The `edits` array accepts multiple `{old_string, new_string, near_line?}` entries applied atomically. All edits are validated before any are applied. Overlapping edits (two entries targeting the same text region) are rejected. Edits are applied in reverse file-position order so character offsets stay stable.
- **Auto-approve**: No -- requires user confirmation.
- **Agent availability**: `task_agent` only.
@@ -270,8 +275,9 @@ Execute Python code for math and computation in a sandbox.
|-----------|--------|----------|-------------|
| `code` | string | yes | Python code to execute. Must use `print()` for output. |
- **What it does**: Runs Python code in a sandboxed environment with pre-imported libraries: `sympy`, `numpy`, `scipy`, `math`, `fractions`, `itertools`, `functools`, `collections`, `decimal`, `operator`, `random`, `re`, `string`. Common sympy names (`symbols`, `solve`, `simplify`, `sqrt`, `Matrix`, etc.) are pre-imported.
- **Auto-approve**: No -- requires user confirmation.
- **What it does**: Runs Python code in a sandboxed environment with pre-imported libraries: `sympy`, `numpy`, `scipy`, `math`, `fractions`, `itertools`, `functools`, `collections`, `decimal`, `operator`, `random`, `re`, `string`. Common sympy names (`symbols`, `solve`, `simplify`, `sqrt`, `Matrix`, etc.) are pre-imported. `pytest` is also available for import.
- **Installation**: `sympy`, `numpy`, `scipy`, and `pytest` require the `[sandbox]` extras group: `pip install turnstone[sandbox]` (included in `[all]`).
- **Auto-approve**: Yes.
- **Agent availability**: `agent` and `task_agent`.
---
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "0.9.2"
version = "0.9.3"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+2 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Console API",
"version": "0.9.1",
"version": "0.9.2",
"description": "Cluster-wide visibility and control across all turnstone nodes."
},
"paths": {
@@ -8583,4 +8583,4 @@
}
}
}
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "turnstone Server API",
"version": "0.9.1",
"version": "0.9.2",
"description": "Single-node workstream management, chat interaction, and real-time streaming."
},
"paths": {
@@ -2043,4 +2043,4 @@
}
}
}
}
}
+10
View File
@@ -38,6 +38,11 @@ export interface StreamEndEvent {
type: "stream_end";
}
export interface StateChangeEvent {
type: "state_change";
state: "idle" | "thinking" | "running" | "attention" | "error";
}
export interface ToolInfoEvent {
type: "tool_info";
items: Array<Record<string, unknown>>;
@@ -150,6 +155,7 @@ export type ServerEvent =
| ContentEvent
| ReasoningEvent
| StreamEndEvent
| StateChangeEvent
| ToolInfoEvent
| ApproveRequestEvent
| ApprovalResolvedEvent
@@ -247,6 +253,10 @@ export function isStreamEndEvent(e: ServerEvent): e is StreamEndEvent {
return e.type === "stream_end";
}
export function isStateChangeEvent(e: ServerEvent): e is StateChangeEvent {
return e.type === "state_change";
}
export function isToolResultEvent(e: ServerEvent): e is ToolResultEvent {
return e.type === "tool_result";
}
+2
View File
@@ -35,6 +35,7 @@ export type {
ContentEvent,
ReasoningEvent,
StreamEndEvent,
StateChangeEvent,
ToolInfoEvent,
ApproveRequestEvent,
ApprovalResolvedEvent,
@@ -65,6 +66,7 @@ export {
isReasoningEvent,
isErrorEvent,
isStreamEndEvent,
isStateChangeEvent,
isToolResultEvent,
isWsStateEvent,
isApproveRequestEvent,
+50 -7
View File
@@ -1273,11 +1273,13 @@ class TestAnthropicOrphanedToolUse:
for block in msg["content"]:
if isinstance(block, dict) and block.get("type") == "tool_result":
tool_results.append(block)
result_map = {r["tool_use_id"]: r for r in tool_results}
assert "c1" in result_map
assert result_map["c1"]["content"] == "file1.txt" # real result
assert "c2" in result_map
assert result_map["c2"]["is_error"] is True # synthetic
# Real result should come before synthetic (ordering matters for Anthropic)
assert len(tool_results) == 2
assert tool_results[0]["tool_use_id"] == "c1"
assert tool_results[0]["content"] == "file1.txt" # real result
assert tool_results[0].get("is_error") is not True
assert tool_results[1]["tool_use_id"] == "c2"
assert tool_results[1]["is_error"] is True # synthetic
def test_complete_results_no_synthesis(self) -> None:
"""All tool_calls have results — no synthesis needed."""
@@ -1294,12 +1296,16 @@ class TestAnthropicOrphanedToolUse:
{"role": "user", "content": "thanks"},
]
_, converted = self.provider._convert_messages(messages)
# No synthetic results — only the real one
# No synthetic results — only the real one (no is_error flag)
tool_results = []
for msg in converted:
if msg["role"] == "user" and isinstance(msg["content"], list):
for block in msg["content"]:
if isinstance(block, dict) and block.get("type") == "tool_result":
assert "cancelled" not in block.get("content", "").lower()
tool_results.append(block)
assert len(tool_results) == 1
assert tool_results[0]["tool_use_id"] == "c1"
assert tool_results[0].get("is_error") is not True
def test_trailing_orphan(self) -> None:
"""Orphaned tool_use at end of conversation (no following messages)."""
@@ -1324,6 +1330,43 @@ class TestAnthropicOrphanedToolUse:
assert tool_results[0]["tool_use_id"] == "c1"
assert tool_results[0]["is_error"] is True
def test_provider_content_orphan(self) -> None:
"""Orphaned tool_use inside _provider_content (Anthropic raw blocks)."""
messages = [
{"role": "user", "content": "run something"},
{
"role": "assistant",
"content": "Running...",
"_provider_content": [
{"type": "text", "text": "Running..."},
{
"type": "tool_use",
"id": "toolu_abc",
"name": "bash",
"input": {"command": "sleep 30"},
},
],
"tool_calls": [
{
"id": "toolu_abc",
"function": {"name": "bash", "arguments": '{"command": "sleep 30"}'},
},
],
},
{"role": "user", "content": "never mind"},
]
_, converted = self.provider._convert_messages(messages)
# Should synthesize a tool_result for the orphaned tool_use in provider_content
tool_results = []
for msg in converted:
if msg["role"] == "user" and isinstance(msg["content"], list):
for block in msg["content"]:
if isinstance(block, dict) and block.get("type") == "tool_result":
tool_results.append(block)
assert len(tool_results) == 1
assert tool_results[0]["tool_use_id"] == "toolu_abc"
assert tool_results[0]["is_error"] is True
class TestAnthropicReasoningNone:
"""Verify 'none' effort disables thinking for manual-thinking models."""
+88
View File
@@ -226,3 +226,91 @@ class TestEdgeCases:
assert len(msgs) == 2
assert msgs[0]["role"] == "user"
assert msgs[1]["role"] == "assistant"
class TestMidConversationOrphanRepair:
"""Mid-conversation orphaned tool_calls get synthetic tool results."""
def test_all_orphaned_mid_conversation(self):
"""Assistant has 2 tool_calls, no tool results, then user message."""
tc = json.dumps(
[
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
]
)
rows = [
_row("user", "do stuff"),
_row("assistant", "Running...", tool_calls=tc),
_row("user", "never mind"),
]
msgs = reconstruct_messages(rows, "ws1")
# Should have: user, assistant, tool(c1), tool(c2), user
assert len(msgs) == 5
assert msgs[2]["role"] == "tool"
assert msgs[2]["tool_call_id"] == "c1"
assert msgs[2]["is_error"] is True
assert msgs[3]["role"] == "tool"
assert msgs[3]["tool_call_id"] == "c2"
assert msgs[4]["role"] == "user"
def test_partial_results_mid_conversation(self):
"""2 tool_calls, 1 result present, 1 missing — synthesize only the missing one."""
tc = json.dumps(
[
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
{"id": "c2", "function": {"name": "write_file", "arguments": "{}"}},
]
)
rows = [
_row("user", "do stuff"),
_row("assistant", "", tool_calls=tc),
_row("tool", "file1.txt", tool_name="bash", tc_id="c1"),
_row("user", "skip the write"),
]
msgs = reconstruct_messages(rows, "ws1")
# Should have: user, assistant, tool(c1 real), tool(c2 synthetic), user
assert len(msgs) == 5
assert msgs[2]["role"] == "tool"
assert msgs[2]["tool_call_id"] == "c1"
assert msgs[2]["content"] == "file1.txt"
assert msgs[2].get("is_error") is not True
assert msgs[3]["role"] == "tool"
assert msgs[3]["tool_call_id"] == "c2"
assert msgs[3]["is_error"] is True
assert msgs[4]["role"] == "user"
def test_complete_results_no_synthesis(self):
"""All tool_calls have results — no synthesis needed."""
tc = json.dumps(
[
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
]
)
rows = [
_row("user", "do it"),
_row("assistant", "", tool_calls=tc),
_row("tool", "done", tool_name="bash", tc_id="c1"),
_row("user", "thanks"),
]
msgs = reconstruct_messages(rows, "ws1")
assert len(msgs) == 4
tool_msgs = [m for m in msgs if m["role"] == "tool"]
assert len(tool_msgs) == 1
assert tool_msgs[0].get("is_error") is not True
def test_trailing_orphan_stripped_not_synthesized(self):
"""Trailing orphan is handled by the existing strip repair, not synthesis."""
tc = json.dumps(
[
{"id": "c1", "function": {"name": "bash", "arguments": "{}"}},
]
)
rows = [
_row("user", "do it"),
_row("assistant", "Running...", tool_calls=tc),
]
msgs = reconstruct_messages(rows, "ws1")
# Trailing strip removes the assistant message entirely
assert len(msgs) == 1
assert msgs[0]["role"] == "user"
+387
View File
@@ -0,0 +1,387 @@
"""Tests for conversation rewind and retry functionality."""
from __future__ import annotations
from unittest.mock import MagicMock
from turnstone.core.session import ChatSession
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class NullUI:
"""UI adapter that discards all output."""
def on_thinking_start(self):
pass
def on_thinking_stop(self):
pass
def on_reasoning_token(self, text):
pass
def on_content_token(self, text):
pass
def on_stream_end(self):
pass
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
pass
def on_status(self, usage, context_window, effort):
pass
def on_plan_review(self, content):
return ""
def on_info(self, message):
pass
def on_error(self, message):
pass
def on_state_change(self, state):
pass
def on_rename(self, name):
pass
def on_output_warning(self, call_id, assessment):
pass
def _make_session(tmp_db) -> ChatSession:
return ChatSession(
client=MagicMock(),
model="test-model",
ui=NullUI(),
instructions="",
temperature=0.5,
max_tokens=4096,
tool_timeout=30,
)
def _populate_simple(session: ChatSession) -> None:
"""Populate with 2 simple turns (no tool calls)."""
session.messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
{"role": "user", "content": "How are you?"},
{"role": "assistant", "content": "I'm fine."},
]
session._msg_tokens = [10, 20, 10, 20]
def _populate_with_tools(session: ChatSession) -> None:
"""Populate with 2 turns, first has tool calls."""
session.messages = [
{"role": "user", "content": "Write a test"},
{
"role": "assistant",
"content": "",
"tool_calls": [
{"id": "tc1", "function": {"name": "bash", "arguments": '{"cmd":"echo hi"}'}}
],
},
{"role": "tool", "tool_call_id": "tc1", "content": "hi"},
{"role": "assistant", "content": "Done."},
{"role": "user", "content": "Fix the import"},
{"role": "assistant", "content": "Fixed."},
]
session._msg_tokens = [10, 20, 10, 20, 10, 20]
# ---------------------------------------------------------------------------
# _find_turn_boundaries
# ---------------------------------------------------------------------------
class TestFindTurnBoundaries:
def test_empty_messages(self, tmp_db):
session = _make_session(tmp_db)
assert session._find_turn_boundaries() == []
def test_single_turn(self, tmp_db):
session = _make_session(tmp_db)
session.messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi!"},
]
assert session._find_turn_boundaries() == [0]
def test_multi_turn(self, tmp_db):
session = _make_session(tmp_db)
_populate_simple(session)
assert session._find_turn_boundaries() == [0, 2]
def test_with_tool_calls(self, tmp_db):
session = _make_session(tmp_db)
_populate_with_tools(session)
assert session._find_turn_boundaries() == [0, 4]
# ---------------------------------------------------------------------------
# rewind
# ---------------------------------------------------------------------------
class TestRewind:
def test_rewind_zero(self, tmp_db):
session = _make_session(tmp_db)
_populate_simple(session)
assert session.rewind(0) == 0
assert len(session.messages) == 4
def test_rewind_one_turn(self, tmp_db):
session = _make_session(tmp_db)
_populate_simple(session)
removed = session.rewind(1)
assert removed == 2 # user + assistant
assert len(session.messages) == 2
assert session.messages[0]["content"] == "Hello"
assert session.messages[1]["content"] == "Hi there!"
assert len(session._msg_tokens) == 2
def test_rewind_all_turns(self, tmp_db):
session = _make_session(tmp_db)
_populate_simple(session)
removed = session.rewind(2)
assert removed == 4
assert len(session.messages) == 0
assert len(session._msg_tokens) == 0
def test_rewind_clamped(self, tmp_db):
"""Rewinding more turns than exist should clamp to available."""
session = _make_session(tmp_db)
_populate_simple(session)
removed = session.rewind(999)
assert removed == 4
assert len(session.messages) == 0
def test_rewind_empty(self, tmp_db):
session = _make_session(tmp_db)
assert session.rewind(1) == 0
def test_rewind_with_tools(self, tmp_db):
"""Rewinding 1 turn on a multi-sub-turn conversation."""
session = _make_session(tmp_db)
_populate_with_tools(session)
removed = session.rewind(1)
assert removed == 2 # user "Fix the import" + assistant "Fixed."
assert len(session.messages) == 4
assert session.messages[-1]["content"] == "Done."
def test_rewind_tokens_sync(self, tmp_db):
"""_msg_tokens stays in sync with messages."""
session = _make_session(tmp_db)
_populate_simple(session)
session.rewind(1)
assert len(session._msg_tokens) == len(session.messages)
# ---------------------------------------------------------------------------
# retry
# ---------------------------------------------------------------------------
class TestRetry:
def test_retry_returns_user_message(self, tmp_db):
session = _make_session(tmp_db)
_populate_simple(session)
msg = session.retry()
assert msg == "How are you?"
# Only Turn 1 remains, without the second user message
assert len(session.messages) == 2
assert session.messages[-1]["content"] == "Hi there!"
def test_retry_empty(self, tmp_db):
session = _make_session(tmp_db)
assert session.retry() is None
def test_retry_with_tools(self, tmp_db):
session = _make_session(tmp_db)
_populate_with_tools(session)
msg = session.retry()
assert msg == "Fix the import"
# Only Turn 1 remains (user + assistant w/tools + tool result + assistant)
assert len(session.messages) == 4
def test_retry_sets_pending(self, tmp_db):
"""handle_command for /retry should set _pending_retry."""
session = _make_session(tmp_db)
_populate_simple(session)
session.handle_command("/retry")
assert session._pending_retry == "How are you?"
def test_retry_tokens_sync(self, tmp_db):
session = _make_session(tmp_db)
_populate_simple(session)
session.retry()
assert len(session._msg_tokens) == len(session.messages)
def test_retry_multipart_content_returns_none(self, tmp_db):
"""retry() should refuse multipart (vision/image) messages."""
session = _make_session(tmp_db)
session.messages = [
{"role": "user", "content": [{"type": "text", "text": "describe this"}]},
{"role": "assistant", "content": "It's an image."},
]
session._msg_tokens = [10, 20]
assert session.retry() is None
# Messages should be unchanged
assert len(session.messages) == 2
def test_retry_none_content_returns_none(self, tmp_db):
"""retry() should handle content=None gracefully."""
session = _make_session(tmp_db)
session.messages = [
{"role": "user", "content": None},
{"role": "assistant", "content": "Ok."},
]
session._msg_tokens = [10, 20]
assert session.retry() is None
# ---------------------------------------------------------------------------
# handle_command integration
# ---------------------------------------------------------------------------
class TestHandleCommand:
def test_rewind_command(self, tmp_db):
session = _make_session(tmp_db)
_populate_simple(session)
session.handle_command("/rewind 1")
assert len(session.messages) == 2
def test_rewind_no_arg(self, tmp_db):
session = _make_session(tmp_db)
ui = session.ui
ui.on_info = MagicMock()
session.handle_command("/rewind")
ui.on_info.assert_called_once()
assert "Usage" in ui.on_info.call_args[0][0]
def test_rewind_invalid_arg(self, tmp_db):
session = _make_session(tmp_db)
ui = session.ui
ui.on_info = MagicMock()
session.handle_command("/rewind abc")
ui.on_info.assert_called_once()
assert "integer" in ui.on_info.call_args[0][0]
def test_retry_nothing_to_retry(self, tmp_db):
session = _make_session(tmp_db)
ui = session.ui
ui.on_info = MagicMock()
session.handle_command("/retry")
ui.on_info.assert_called_once()
assert "Nothing" in ui.on_info.call_args[0][0]
# ---------------------------------------------------------------------------
# Storage integration — delete_messages_after
# ---------------------------------------------------------------------------
class TestDeleteMessagesAfter:
def test_delete_truncates_db(self, tmp_db):
from turnstone.core.memory import (
delete_messages_after,
load_messages,
register_workstream,
save_message,
)
ws_id = "test-ws-delete"
register_workstream(ws_id)
save_message(ws_id, "user", "Hello")
save_message(ws_id, "assistant", "Hi!")
save_message(ws_id, "user", "Bye")
save_message(ws_id, "assistant", "Goodbye!")
deleted = delete_messages_after(ws_id, 2)
assert deleted == 2
msgs = load_messages(ws_id)
assert len(msgs) == 2
assert msgs[0]["content"] == "Hello"
assert msgs[1]["content"] == "Hi!"
def test_delete_nothing(self, tmp_db):
from turnstone.core.memory import (
delete_messages_after,
register_workstream,
save_message,
)
ws_id = "test-ws-noop"
register_workstream(ws_id)
save_message(ws_id, "user", "Hello")
deleted = delete_messages_after(ws_id, 10)
assert deleted == 0
def test_delete_all(self, tmp_db):
from turnstone.core.memory import (
delete_messages_after,
load_messages,
register_workstream,
save_message,
)
ws_id = "test-ws-all"
register_workstream(ws_id)
save_message(ws_id, "user", "Hello")
save_message(ws_id, "assistant", "Hi!")
deleted = delete_messages_after(ws_id, 0)
assert deleted == 2
assert load_messages(ws_id) == []
# ---------------------------------------------------------------------------
# End-to-end: rewind + DB sync
# ---------------------------------------------------------------------------
class TestRewindDBSync:
def test_rewind_persists_to_db(self, tmp_db):
from turnstone.core.memory import load_messages, register_workstream, save_message
session = _make_session(tmp_db)
ws_id = session.ws_id
register_workstream(ws_id)
# Persist messages to DB and set in-memory state
save_message(ws_id, "user", "Hello")
save_message(ws_id, "assistant", "Hi!")
save_message(ws_id, "user", "Bye")
save_message(ws_id, "assistant", "Goodbye!")
session.messages = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi!"},
{"role": "user", "content": "Bye"},
{"role": "assistant", "content": "Goodbye!"},
]
session._msg_tokens = [5, 5, 5, 5]
session.rewind(1)
# Verify DB matches in-memory state
db_msgs = load_messages(ws_id)
assert len(db_msgs) == 2
assert db_msgs[0]["content"] == "Hello"
assert db_msgs[1]["content"] == "Hi!"
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "0.9.2"
__version__ = "0.9.3"
+12
View File
@@ -63,6 +63,8 @@ SLASH_COMMANDS = [
"/creative",
"/debug",
"/mcp",
"/retry",
"/rewind",
"/help",
"/exit",
"/quit",
@@ -1254,6 +1256,16 @@ def main() -> None:
should_exit = active.session.handle_command(user_input)
if should_exit:
break
# Dispatch deferred retry (handle_command sets _pending_retry)
retry_msg = active.session._pending_retry
if retry_msg:
active.session._pending_retry = None
try:
active.session.send(retry_msg)
except KeyboardInterrupt:
print(f"\n{yellow('Interrupted.')}")
except Exception as e:
print(f"\n{red(f'Error: {e}')}")
else:
try:
active.session.send(user_input)
+1
View File
@@ -1771,6 +1771,7 @@ _VALID_PERMISSIONS = frozenset(
"tools.approve",
"workstreams.create",
"workstreams.close",
"conversation.modify",
}
)
+17
View File
@@ -65,6 +65,23 @@ def load_messages(ws_id: str) -> list[dict[str, Any]]:
return []
def delete_messages_after(ws_id: str, keep_count: int) -> int:
"""Delete conversation rows beyond the first *keep_count* rows.
Returns the number of rows deleted, or 0 on error.
"""
try:
return get_storage().delete_messages_after(ws_id, keep_count)
except Exception:
log.warning(
"Failed to delete messages after count=%d for ws=%s",
keep_count,
ws_id,
exc_info=True,
)
return 0
# -- Workstream management ----------------------------------------------------
+71 -11
View File
@@ -290,6 +290,7 @@ class AnthropicProvider:
"""
system_parts: list[str] = []
converted: list[dict[str, Any]] = []
pending_orphan_results: list[dict[str, Any]] = []
i = 0
while i < len(messages):
@@ -303,11 +304,50 @@ class AnthropicProvider:
continue
if role == "assistant":
# Safety: flush any unconsumed synthetic results from a prior
# assistant message (should not happen with well-formed data).
if pending_orphan_results:
converted.append({"role": "user", "content": pending_orphan_results})
pending_orphan_results = []
# If raw provider content was preserved, pass it through verbatim
# so encrypted_content/encrypted_index from web search are retained
provider_content = msg.get("_provider_content")
if provider_content:
converted.append({"role": "assistant", "content": provider_content})
# Check for orphaned tool_use in provider content too
if isinstance(provider_content, list):
pc_tool_ids = [
b["id"]
for b in provider_content
if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id")
]
if pc_tool_ids:
j = i + 1
result_ids_pc: set[str] = set()
while j < len(messages) and messages[j]["role"] == "tool":
tc_id = messages[j].get("tool_call_id", "")
if tc_id:
result_ids_pc.add(tc_id)
j += 1
orphaned_pc = [uid for uid in pc_tool_ids if uid not in result_ids_pc]
if orphaned_pc:
log.debug(
"Synthesizing %d tool_result(s) for orphaned provider_content tool_use IDs",
len(orphaned_pc),
)
synthetic_pc = [
{
"type": "tool_result",
"tool_use_id": uid,
"content": "Tool execution was cancelled.",
"is_error": True,
}
for uid in orphaned_pc
]
if j == i + 1:
converted.append({"role": "user", "content": synthetic_pc})
else:
pending_orphan_results = synthetic_pc
i += 1
continue
@@ -339,20 +379,28 @@ class AnthropicProvider:
# happens when a cancel interrupts tool execution — the
# assistant message is saved to DB before tools run, but
# GenerationCancelled prevents tool results from being created.
tool_use_ids = {b["id"] for b in content_blocks if b.get("type") == "tool_use"}
# Collect IDs in order, skip empty IDs (from malformed tool calls).
tool_use_ids = [
b["id"] for b in content_blocks if b.get("type") == "tool_use" and b.get("id")
]
if tool_use_ids:
# Peek ahead to collect tool_result IDs
j = i + 1
result_ids: set[str] = set()
while j < len(messages) and messages[j]["role"] == "tool":
result_ids.add(messages[j].get("tool_call_id", ""))
tc_id = messages[j].get("tool_call_id", "")
if tc_id:
result_ids.add(tc_id)
j += 1
orphaned = tool_use_ids - result_ids
orphaned = [uid for uid in tool_use_ids if uid not in result_ids]
if orphaned:
log.debug(
"Synthesizing %d tool_result(s) for orphaned tool_use IDs",
len(orphaned),
)
# Store for deferred injection — synthetic results are
# appended after any real tool results so
# _merge_consecutive produces them in tool_use order.
synthetic = [
{
"type": "tool_result",
@@ -362,7 +410,14 @@ class AnthropicProvider:
}
for uid in orphaned
]
converted.append({"role": "user", "content": synthetic})
if j == i + 1:
# No real tool messages follow — inject immediately
converted.append({"role": "user", "content": synthetic})
else:
# Real tool messages follow — they'll be converted
# next iteration. Stash synthetic results to append
# after them.
pending_orphan_results = synthetic
i += 1
continue
@@ -376,14 +431,19 @@ class AnthropicProvider:
# Convert image_url parts to Anthropic image format
if isinstance(content, list):
content = self._convert_content_parts(content)
tool_results.append(
{
"type": "tool_result",
"tool_use_id": tool_msg.get("tool_call_id", ""),
"content": content,
}
)
result_block: dict[str, Any] = {
"type": "tool_result",
"tool_use_id": tool_msg.get("tool_call_id", ""),
"content": content,
}
if tool_msg.get("is_error"):
result_block["is_error"] = True
tool_results.append(result_block)
i += 1
# Append any deferred synthetic results after real ones
if pending_orphan_results:
tool_results.extend(pending_orphan_results)
pending_orphan_results = []
converted.append({"role": "user", "content": tool_results})
continue
+83
View File
@@ -35,6 +35,7 @@ from turnstone.core.edit import find_occurrences, pick_nearest
from turnstone.core.log import get_logger
from turnstone.core.memory import (
count_structured_memories,
delete_messages_after,
delete_structured_memory,
delete_workstream,
get_skill_by_name,
@@ -361,6 +362,7 @@ class ChatSession:
self._active_procs: set[subprocess.Popen[str]] = set() # for force-kill
self._procs_lock = threading.Lock()
self._cancelled_partial_msg: dict[str, Any] | None = None
self._pending_retry: str | None = None
# Intent validation judge (lazy-initialized)
self._judge_config: JudgeConfig | None = judge_config
self._judge: IntentJudge | None = None
@@ -1642,6 +1644,54 @@ class ChatSession:
self._emit_state("error")
raise
# -- Rewind / retry -------------------------------------------------------
def _find_turn_boundaries(self) -> list[int]:
"""Return indices of user messages in self.messages (turn start positions)."""
return [i for i, m in enumerate(self.messages) if m["role"] == "user"]
def rewind(self, n: int) -> int:
"""Drop the last *n* complete turns from the conversation.
A turn = user message + all assistant/tool messages until the next
user message. Returns the number of messages removed. Updates
both in-memory state and the persistent database.
"""
if n < 1:
return 0
boundaries = self._find_turn_boundaries()
if not boundaries:
return 0
n = min(n, len(boundaries))
cut_index = boundaries[-n]
removed_count = len(self.messages) - cut_index
del self.messages[cut_index:]
del self._msg_tokens[cut_index:]
delete_messages_after(self._ws_id, len(self.messages))
return removed_count
def retry(self) -> str | None:
"""Drop the last assistant response and return the user message to re-send.
The caller is responsible for calling ``send()`` with the returned
message. Returns ``None`` if there is nothing to retry.
"""
boundaries = self._find_turn_boundaries()
if not boundaries:
return None
last_user_idx = boundaries[-1]
content = self.messages[last_user_idx].get("content")
# Multipart messages (vision/images) have list-type content;
# retry only supports plain text.
if not isinstance(content, str) or not content:
return None
# Drop everything from (and including) the user message onward;
# send() will re-append the user message.
del self.messages[last_user_idx:]
del self._msg_tokens[last_user_idx:]
delete_messages_after(self._ws_id, len(self.messages))
return content
@staticmethod
def _strip_reasoning(text: str) -> str:
"""Remove <think>/<reasoning> tags and their content."""
@@ -5820,6 +5870,37 @@ class ChatSession:
else:
self.ui.on_info("\n".join(mcp_lines))
elif cmd == "/retry":
user_msg = self.retry()
if user_msg is None:
self.ui.on_info("Nothing to retry.")
else:
self._pending_retry = user_msg
self.ui.on_info(f"Retrying: {user_msg[:80]}...")
elif cmd == "/rewind":
if not arg:
self.ui.on_info("Usage: /rewind <N> — drop the last N turns")
else:
try:
n = int(arg)
except ValueError:
self.ui.on_info("Usage: /rewind <N> — N must be a positive integer")
else:
if n < 1:
self.ui.on_info("N must be at least 1.")
else:
turns_available = len(self._find_turn_boundaries())
actual_n = min(n, turns_available)
removed = self.rewind(n)
if removed == 0:
self.ui.on_info("No turns to rewind.")
else:
self.ui.on_info(
f"Rewound {actual_n} turn(s) ({removed} messages removed). "
f"{len(self.messages)} messages remain."
)
elif cmd == "/help":
self.ui.on_info(
"\n".join(
@@ -5837,6 +5918,8 @@ class ChatSession:
"",
" /history [query] Search conversation history (or show recent)",
" /compact Compact conversation (summarize old messages)",
" /retry Re-send the last user message for a new response",
" /rewind <N> Drop the last N turns (user + response)",
"",
" /model [alias] Show/switch model (alias from config)",
" /raw Toggle reasoning content display",
+23
View File
@@ -147,6 +147,29 @@ class PostgreSQLBackend:
).fetchall()
return _reconstruct_messages(list(rows), ws_id)
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
with self._engine.connect() as conn:
cutoff_row = conn.execute(
sa.select(conversations.c.id)
.where(conversations.c.ws_id == ws_id)
.order_by(conversations.c.id)
.limit(1)
.offset(keep_count)
).fetchone()
if cutoff_row is None:
return 0
cutoff_id = cutoff_row[0]
result = conn.execute(
sa.delete(conversations).where(
sa.and_(
conversations.c.ws_id == ws_id,
conversations.c.id >= cutoff_id,
)
)
)
conn.commit()
return result.rowcount
# -- Workstream management -------------------------------------------------
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
+9
View File
@@ -32,6 +32,15 @@ class StorageBackend(Protocol):
"""Load messages for a workstream and reconstruct OpenAI message format."""
...
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
"""Delete conversation rows beyond the first *keep_count* rows for a workstream.
Rows are ordered by auto-increment ``id``. If the workstream has
N rows total and ``keep_count`` < N, the last N - keep_count rows
are deleted. Returns the number of rows deleted.
"""
...
# -- Workstream management -------------------------------------------------
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
+37
View File
@@ -212,6 +212,43 @@ class SQLiteBackend:
return _reconstruct_messages(list(rows), ws_id)
def delete_messages_after(self, ws_id: str, keep_count: int) -> int:
with self._engine.connect() as conn:
# Find the id of the first row to delete (the row at offset keep_count)
cutoff_row = conn.execute(
sa.select(conversations.c.id)
.where(conversations.c.ws_id == ws_id)
.order_by(conversations.c.id)
.limit(1)
.offset(keep_count)
).fetchone()
if cutoff_row is None:
return 0 # nothing to delete
cutoff_id = cutoff_row[0]
# Remove FTS5 entries first (external content table doesn't auto-sync)
if self._fts5_available:
try:
conn.execute(
sa.text(
"DELETE FROM conversations_fts WHERE rowid IN "
"(SELECT id FROM conversations "
" WHERE ws_id = :ws_id AND id >= :cutoff_id)"
),
{"ws_id": ws_id, "cutoff_id": cutoff_id},
)
except Exception:
self._fts5_available = False
result = conn.execute(
sa.delete(conversations).where(
sa.and_(
conversations.c.ws_id == ws_id,
conversations.c.id >= cutoff_id,
)
)
)
conn.commit()
return result.rowcount
# -- Workstream management -------------------------------------------------
def list_workstreams_with_history(self, limit: int = 20) -> list[Any]:
+41
View File
@@ -200,4 +200,45 @@ def reconstruct_messages(rows: list[Any], ws_id: str) -> list[dict[str, Any]]:
break
del messages[asst_idx:]
# Repair: synthesize tool results for mid-conversation orphaned tool calls.
# This happens when a cancel interrupts tool execution — the assistant
# message with tool_calls is saved to DB but GenerationCancelled prevents
# tool results from being created. Both Anthropic (strict) and OpenAI
# (lenient today, may tighten) benefit from well-formed histories.
i = 0
while i < len(messages):
msg = messages[i]
if msg.get("role") == "assistant" and msg.get("tool_calls"):
expected_ids = [tc.get("id", "") for tc in msg["tool_calls"] if tc.get("id")]
# Collect tool result IDs that follow
j = i + 1
result_ids: set[str] = set()
while j < len(messages) and messages[j].get("role") == "tool":
tc_id = messages[j].get("tool_call_id", "")
if tc_id:
result_ids.add(tc_id)
j += 1
# Synthesize results for any missing IDs
orphaned = [uid for uid in expected_ids if uid not in result_ids]
if orphaned:
synthetic = [
{
"role": "tool",
"tool_call_id": uid,
"content": "Tool execution was cancelled.",
"is_error": True,
}
for uid in orphaned
]
# Insert after the last existing tool result (or after assistant)
messages[j:j] = synthetic
if orphaned:
i = j + len(orphaned) # skip past spliced synthetics
elif j > i + 1:
i = j # skip past existing tool block
else:
i += 1 # no tools followed; just advance
else:
i += 1
return messages
@@ -0,0 +1,44 @@
"""Grant conversation.modify permission to admin and operator roles.
Revision ID: 029
Revises: 028
Create Date: 2026-03-29
"""
import sqlalchemy as sa
from alembic import op
revision = "029"
down_revision = "028"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
# Grant to admin role
conn.execute(
sa.text(
"UPDATE roles SET permissions = permissions || ',conversation.modify' "
"WHERE role_id = 'builtin-admin' "
"AND permissions NOT LIKE '%conversation.modify%'"
)
)
# Grant to operator role
conn.execute(
sa.text(
"UPDATE roles SET permissions = permissions || ',conversation.modify' "
"WHERE role_id = 'builtin-operator' "
"AND permissions NOT LIKE '%conversation.modify%'"
)
)
def downgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"UPDATE roles SET permissions = REPLACE(permissions, ',conversation.modify', '') "
"WHERE role_id IN ('builtin-admin', 'builtin-operator')"
)
)
+84 -1
View File
@@ -805,6 +805,22 @@ def _get_ws(
return None, None
def _audit_context(request: Request) -> tuple[str, str]:
"""Extract (user_id, ip_address) from request for audit logging."""
auth = getattr(getattr(request, "state", None), "auth_result", None)
uid: str = auth.user_id if auth else ""
ip = ""
if request.client:
ip = request.client.host
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
from turnstone.core.auth import is_secure_request
if is_secure_request(dict(request.headers), request.url.scheme):
ip = forwarded.split(",")[0].strip()
return uid, ip
# ---------------------------------------------------------------------------
# Route handlers — all async
# ---------------------------------------------------------------------------
@@ -1331,11 +1347,30 @@ async def command(request: Request) -> JSONResponse:
assert ws.session is not None
try:
# Permission gate for conversation-modifying commands
cmd_word = cmd.strip().split(None, 1)[0].lower()
if cmd_word in ("/rewind", "/retry"):
from turnstone.core.auth import require_permission
err = require_permission(request, "conversation.modify")
if err:
ui.on_error("Permission denied: conversation.modify required")
return err
# Prevent rewind/retry while a generation is in progress
with ws._lock:
if ws.worker_thread and ws.worker_thread.is_alive():
ui._enqueue(
{
"type": "busy_error",
"message": "Cannot rewind/retry while processing.",
}
)
return JSONResponse({"status": "busy"})
should_exit = ws.session.handle_command(cmd)
if should_exit:
ui.on_info("Session ended. You can close this tab.")
# Handle UI updates for workstream-changing commands
cmd_word = cmd.strip().split(None, 1)[0].lower()
if cmd_word in ("/clear", "/new"):
ui._enqueue({"type": "clear_ui"})
elif cmd_word == "/resume":
@@ -1343,6 +1378,54 @@ async def command(request: Request) -> JSONResponse:
history = _build_history(ws.session)
if history:
ui._enqueue({"type": "history", "messages": history})
elif cmd_word in ("/rewind", "/retry"):
# Refresh frontend with truncated history
ui._enqueue({"type": "clear_ui"})
history = _build_history(ws.session)
if history:
ui._enqueue({"type": "history", "messages": history})
# Audit trail
storage = getattr(request.app.state, "auth_storage", None)
if storage:
from turnstone.core.audit import record_audit
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
f"conversation.{cmd_word[1:]}",
"workstream",
ws.id,
{"command": cmd, "ws_id": ws.id},
ip,
)
# Dispatch deferred retry in background thread
retry_msg = ws.session._pending_retry
if retry_msg:
ws.session._pending_retry = None
session = ws.session
def run_retry() -> None:
me = threading.current_thread()
try:
session.send(retry_msg)
except GenerationCancelled:
if ws.worker_thread is me:
ui.on_stream_end()
ui.on_state_change("idle")
except Exception as exc:
if ws.worker_thread is me:
ui.on_error(f"Error: {exc}")
ui.on_stream_end()
ui.on_state_change("error")
with ws._lock:
if ws.worker_thread and ws.worker_thread.is_alive():
ui.on_error("Cannot retry: workstream is busy")
else:
t = threading.Thread(target=run_retry, daemon=True)
ws.worker_thread = t
t.start()
# Sync in-memory workstream name after any command that can change it.
# This ensures /api/workstreams and future page loads see the right name.
if cmd_word in ("/name", "/resume"):
+233
View File
@@ -32,6 +32,7 @@ function Pane(wsId) {
this.statusText = "";
this._cancelTimeout = null;
this._forceTimeout = null;
this._pendingEditSend = null;
this._createDOM();
}
@@ -181,6 +182,7 @@ Pane.prototype.reset = function () {
this.setBusy(false);
this.pendingApproval = false;
this.approvalBlockEl = null;
this._pendingEditSend = null;
this.inputEl.disabled = false;
};
@@ -211,6 +213,7 @@ Pane.prototype.disconnectSSE = function () {
Pane.prototype.setBusy = function (b) {
this.busy = b;
this.messagesEl.dataset.busy = b ? "true" : "false";
this.sendBtn.disabled = b;
this.sendBtn.style.display = b ? "none" : "";
this.stopBtn.style.display = b ? "" : "none";
@@ -394,6 +397,7 @@ Pane.prototype.handleEvent = function (evt) {
case "state_change":
if (evt.state === "idle" || evt.state === "error") {
this.setBusy(false);
this._attachRetryToLastAssistant();
// Only steal focus if this is the active pane and no approval pending.
if (this.id === focusedPaneId && !this.pendingApproval) {
this.inputEl.focus();
@@ -527,6 +531,21 @@ Pane.prototype.handleEvent = function (evt) {
case "history":
this.replayHistory(evt.messages);
// Dispatch pending edit-and-resend after rewind history arrives
if (this._pendingEditSend) {
var editText = this._pendingEditSend;
this._pendingEditSend = null;
this.setBusy(true);
this.addUserMessage(editText);
authFetch("/v1/api/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: editText, ws_id: self.wsId }),
}).catch(function (err) {
self.addErrorMessage("Connection error: " + err.message);
self.setBusy(false);
});
}
break;
case "clear_ui":
@@ -554,10 +573,208 @@ Pane.prototype.addUserMessage = function (text) {
var el = document.createElement("div");
el.className = "msg msg-user";
el.textContent = text;
this._addUserMsgActions(el, text);
this.messagesEl.appendChild(el);
this.scrollToBottom(true);
};
Pane.prototype._addUserMsgActions = function (el, text) {
var self = this;
var bar = document.createElement("div");
bar.className = "msg-actions";
bar.setAttribute("role", "toolbar");
bar.setAttribute("aria-label", "Message actions");
// Edit button
var editBtn = document.createElement("button");
editBtn.className = "msg-action-btn";
editBtn.title = "Edit & resend";
editBtn.setAttribute("aria-label", "Edit and resend this message");
var editIcon = document.createElement("span");
editIcon.className = "icon-edit";
editIcon.setAttribute("aria-hidden", "true");
editBtn.appendChild(editIcon);
editBtn.addEventListener("click", function (e) {
e.stopPropagation();
self._startEdit(el, text);
});
bar.appendChild(editBtn);
// Rewind-to-here button
var rewindBtn = document.createElement("button");
rewindBtn.className = "msg-action-btn";
rewindBtn.title = "Rewind to before this message";
rewindBtn.setAttribute(
"aria-label",
"Rewind conversation to before this message",
);
var rewindIcon = document.createElement("span");
rewindIcon.className = "icon-rewind";
rewindIcon.setAttribute("aria-hidden", "true");
rewindBtn.appendChild(rewindIcon);
rewindBtn.addEventListener("click", function (e) {
e.stopPropagation();
self._rewindToMessage(el);
});
bar.appendChild(rewindBtn);
el.appendChild(bar);
};
Pane.prototype._addRetryAction = function (el) {
var self = this;
var bar = el.querySelector(".msg-actions");
if (!bar) {
bar = document.createElement("div");
bar.className = "msg-actions";
bar.setAttribute("role", "toolbar");
bar.setAttribute("aria-label", "Message actions");
el.appendChild(bar);
}
var btn = document.createElement("button");
btn.className = "msg-action-btn";
btn.title = "Retry (regenerate response)";
btn.setAttribute("aria-label", "Retry last response");
var icon = document.createElement("span");
icon.className = "icon-retry";
icon.setAttribute("aria-hidden", "true");
btn.appendChild(icon);
btn.addEventListener("click", function (e) {
e.stopPropagation();
self._retryLast();
});
bar.insertBefore(btn, bar.firstChild);
};
Pane.prototype._retryLast = function () {
if (this.busy) return;
var self = this;
authFetch("/v1/api/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: "/retry", ws_id: this.wsId }),
}).catch(function (err) {
self.addErrorMessage("Retry failed: " + err.message);
});
};
Pane.prototype._rewindToMessage = function (msgEl) {
if (this.busy) return;
var self = this;
// Count how many user messages come at or after this one
var userMsgs = this.messagesEl.querySelectorAll(".msg-user");
var idx = Array.prototype.indexOf.call(userMsgs, msgEl);
if (idx < 0) return;
var turnsToRewind = userMsgs.length - idx;
if (turnsToRewind < 1) return;
authFetch("/v1/api/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
command: "/rewind " + turnsToRewind,
ws_id: this.wsId,
}),
}).catch(function (err) {
self.addErrorMessage("Rewind failed: " + err.message);
});
};
Pane.prototype._startEdit = function (msgEl, originalText) {
if (this.busy) return;
var self = this;
// Save current child nodes for cancel restoration
var savedNodes = [];
while (msgEl.firstChild) {
savedNodes.push(msgEl.removeChild(msgEl.firstChild));
}
msgEl.classList.add("msg-editing");
var form = document.createElement("div");
form.className = "msg-edit-form";
var textarea = document.createElement("textarea");
textarea.className = "msg-edit-textarea";
textarea.setAttribute("aria-label", "Edit message text");
textarea.value = originalText;
textarea.rows = Math.min(originalText.split("\n").length + 1, 8);
form.appendChild(textarea);
var actions = document.createElement("div");
actions.className = "msg-edit-actions";
var cancelBtn = document.createElement("button");
cancelBtn.className = "msg-edit-btn";
cancelBtn.textContent = "Cancel";
cancelBtn.addEventListener("click", function () {
// Restore original nodes
while (msgEl.firstChild) msgEl.removeChild(msgEl.firstChild);
savedNodes.forEach(function (n) {
msgEl.appendChild(n);
});
msgEl.classList.remove("msg-editing");
});
actions.appendChild(cancelBtn);
var sendBtn = document.createElement("button");
sendBtn.className = "msg-edit-btn msg-edit-btn-send";
sendBtn.textContent = "Send";
sendBtn.addEventListener("click", function () {
var newText = textarea.value.trim();
if (!newText) return;
self._editAndResend(msgEl, newText);
});
actions.appendChild(sendBtn);
// Ctrl+Enter to send, Escape to cancel
textarea.addEventListener("keydown", function (e) {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
sendBtn.click();
} else if (e.key === "Escape") {
e.preventDefault();
cancelBtn.click();
}
});
form.appendChild(actions);
msgEl.appendChild(form);
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
};
Pane.prototype._editAndResend = function (msgEl, newText) {
if (this.busy) return;
var self = this;
// Count turns to rewind (from this message onward)
var userMsgs = this.messagesEl.querySelectorAll(".msg-user");
var idx = Array.prototype.indexOf.call(userMsgs, msgEl);
if (idx < 0) return;
var turnsToRewind = userMsgs.length - idx;
this.setBusy(true);
// Store pending send — dispatched when the rewind history event arrives
this._pendingEditSend = newText;
authFetch("/v1/api/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
command: "/rewind " + turnsToRewind,
ws_id: self.wsId,
}),
})
.then(function (r) {
if (r && !r.ok) {
self._pendingEditSend = null;
self.setBusy(false);
self.addErrorMessage(
"Rewind failed (HTTP " + r.status + " " + r.statusText + ")",
);
}
})
.catch(function (err) {
self._pendingEditSend = null;
self.addErrorMessage("Rewind failed: " + err.message);
self.setBusy(false);
});
};
Pane.prototype.replayHistory = function (messages) {
var self = this;
this.messagesEl.innerHTML = "";
@@ -660,9 +877,25 @@ Pane.prototype.replayHistory = function (messages) {
}
}
}
this._attachRetryToLastAssistant();
this.scrollToBottom();
};
Pane.prototype._attachRetryToLastAssistant = function () {
// Remove any previous retry buttons
var old = this.messagesEl.querySelectorAll(".msg-assistant .msg-actions");
for (var i = 0; i < old.length; i++) old[i].parentNode.removeChild(old[i]);
// Find the last assistant message with content and add retry
var assistants = this.messagesEl.querySelectorAll(".msg-assistant");
if (assistants.length) {
var last = assistants[assistants.length - 1];
// Only add if it's not a reasoning block
if (!last.classList.contains("reasoning")) {
this._addRetryAction(last);
}
}
};
Pane.prototype.showInlineToolBlock = function (
items,
autoApproved,
+178
View File
@@ -625,6 +625,184 @@ body { position: static; }
border-radius: 2px;
}
/* ==========================================================================
Message action toolbar (hover controls for retry / edit / rewind)
========================================================================== */
.msg-user, .msg-assistant { position: relative; }
.msg-actions {
position: absolute;
top: 4px;
right: 4px;
display: flex;
gap: 1px;
opacity: 0;
pointer-events: none;
transition: opacity 0.12s ease;
background: var(--bg-surface);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
overflow: hidden;
z-index: 2;
}
.msg-user:hover .msg-actions,
.msg-assistant:hover .msg-actions,
.msg-user:focus-within .msg-actions,
.msg-assistant:focus-within .msg-actions,
.msg-actions:hover { opacity: 1; pointer-events: auto; }
.msg-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
background: transparent;
border: none;
cursor: pointer;
color: var(--fg-dim);
transition: color 0.1s ease, background 0.1s ease;
padding: 0;
}
.msg-action-btn:hover { color: var(--accent); background: var(--bg-highlight); box-shadow: 0 0 6px var(--accent-glow); }
.msg-action-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.msg-action-btn + .msg-action-btn { border-left: 1px solid var(--border); }
/* Icon: retry (circular arrow) */
.icon-retry {
width: 13px; height: 13px;
border: 1.5px solid currentColor;
border-radius: 50%;
border-bottom-color: transparent;
position: relative;
}
.icon-retry::after {
content: "";
position: absolute;
bottom: -1px; right: -1px;
width: 0; height: 0;
border-left: 2px solid transparent;
border-right: 2px solid transparent;
border-top: 3px solid currentColor;
transform: rotate(-30deg);
}
/* Icon: edit (pencil) */
.icon-edit {
width: 12px; height: 12px;
position: relative;
transform: rotate(-45deg);
}
.icon-edit::before {
content: "";
position: absolute;
top: 0; left: 3px;
width: 6px; height: 8px;
border: 1.5px solid currentColor;
border-radius: 1px 1px 0 0;
box-sizing: border-box;
}
.icon-edit::after {
content: "";
position: absolute;
bottom: 0; left: 3px;
width: 0; height: 0;
border-left: 3px solid transparent;
border-right: 3px solid transparent;
border-top: 3px solid currentColor;
}
/* Icon: rewind (chevrons pointing left) */
.icon-rewind {
width: 14px; height: 12px;
position: relative;
}
.icon-rewind::before, .icon-rewind::after {
content: "";
position: absolute;
top: 1px;
width: 6px; height: 6px;
border-left: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: rotate(45deg);
}
.icon-rewind::before { left: 1px; }
.icon-rewind::after { left: 6px; }
/* Edit-in-place form */
.msg-edit-form {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.msg-edit-textarea {
width: 100%;
min-height: 40px;
max-height: 200px;
background: var(--bg);
color: var(--fg-bright);
border: 1px solid var(--accent-dim);
border-radius: var(--radius-sm);
padding: 8px 10px;
font-family: var(--font-display);
font-size: 14px;
line-height: 1.5;
resize: vertical;
outline: none;
transition: border-color 0.12s ease;
box-sizing: border-box;
}
.msg-edit-textarea:focus { border-color: var(--accent); }
.msg-edit-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.msg-edit-btn {
padding: 4px 14px;
font-size: 12px;
font-family: var(--font-display);
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
border: 1px solid var(--border-strong);
background: var(--bg);
color: var(--fg);
transition: background 0.1s ease, border-color 0.1s ease, color 0.1s ease;
}
.msg-edit-btn:hover { background: var(--bg-highlight); }
.msg-edit-btn-send {
background: var(--accent-dim);
color: var(--accent);
border-color: var(--accent);
}
.msg-edit-btn-send:hover { background: var(--accent); color: #fff; }
/* Edit-in-place active state */
.msg-editing { background: var(--bg-surface); border-color: var(--accent-dim); }
.msg-editing .msg-actions { display: none; }
/* Busy-state disables action buttons */
[data-busy="true"] .msg-action-btn { opacity: 0.3; pointer-events: none; cursor: not-allowed; }
/* Touch devices: always show action buttons inline */
@media (hover: none) and (pointer: coarse) {
.msg-actions {
opacity: 1;
pointer-events: auto;
position: static;
margin-top: 6px;
border: none;
background: transparent;
justify-content: flex-end;
}
.msg-action-btn { width: 36px; height: 36px; }
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
.msg-actions, .msg-action-btn, .msg-edit-textarea, .msg-edit-btn { transition: none; }
}
/* ==========================================================================
Input area
========================================================================== */
Generated
+1 -1
View File
@@ -2506,7 +2506,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "0.9.2"
version = "0.9.3"
source = { editable = "." }
dependencies = [
{ name = "alembic" },