mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 23:42:25 -06:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a996f0067 | |||
| a465ac6383 | |||
| a4539923e4 | |||
| 42e99d6990 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,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,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 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1771,6 +1771,7 @@ _VALID_PERMISSIONS = frozenset(
|
||||
"tools.approve",
|
||||
"workstreams.create",
|
||||
"workstreams.close",
|
||||
"conversation.modify",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 ----------------------------------------------------
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
========================================================================== */
|
||||
|
||||
Reference in New Issue
Block a user