Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/cloud-contract-surface.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,51 @@ Routes the kocoro agent itself calls additionally need a matching reference unde
`internal/skills/bundled/skills/kocoro/references/`; see the Doc Co-Maintenance
section of `CLAUDE.md`.

Conversation context actions are Desktop-only and gated by
`conversation_context_actions_v1`: `POST /sessions/{id}/fork` copies model
history through a complete assistant turn into a normal persisted session.
Its optional `agent` identifies the source session directory; optional
`target_agent` selects the destination agent directory (use `"default"` for
Default, and omit it to branch within the source agent).
`POST /sessions/{id}/side-chat` runs against that same bounded history plus the
panel's temporary user/assistant history. Side-chat runs carry the normal tool
registry and permission engine — identical capability to the primary
conversation. Tool approvals flow over the per-request SSE stream
(`approval` frames, resolved via `POST /approval`) exactly like `/message`;
`ask_user_question` gets no asker on ephemeral runs (the panel has no question
UI) and degrades to its clean "can't ask here" result. The runs stay ephemeral:
no session is persisted and no global bus events are published. The
implementation is `internal/daemon/conversation_context.go`; the Desktop
consumer is `DaemonClient+ConversationContext.swift`. Neither route belongs in
the bundled Kocoro skill references because the model never calls them.

`message_index` on both routes is a boundary in the RAW archive index space —
the `messages` array of the session file, system-injected entries included —
equal to (index of the last included message) + 1, and it must land on a
complete assistant turn. Desktop derives it from `SessionDisplayMapper`'s
`rawIndex` (raw `enumerated()` position, injected entries skipped for display
but never renumbered), so the two sides share one basis; this was cross-checked
against the Desktop implementation on 2026-08-17. Both routes honor a
compaction checkpoint whose coverage ends at or before the boundary: the fork
carries the checkpoint (deep-copied) and side-chat feeds the model
checkpoint+tail, never the full raw archive.

Desktop text replies use a transient head-only `<kocoro_replies>` prompt
envelope. `RunAgent` removes that envelope from the archived user message but
persists its decoded quotes and comments in the parallel
`message_meta[].conversation_annotations` display metadata. The metadata never
enters `HistoryForLoop`; it lets Desktop reconstruct the compact annotation
attachment immediately and after reload without exposing model-only markup.
The envelope limits are enforced server-side at both Desktop run ingresses
(`POST /message`, `POST /queue`) on the exact bytes the
model would receive: ≤ 100 replies per envelope run, quotes ≤ 8,000 runes,
comments ≤ 2,000 runes. Violations are 400s with stable codes
`conversation_replies_too_many` / `conversation_reply_quote_too_long` /
`conversation_reply_comment_too_long`, and a delimited-but-unparseable
envelope is `conversation_replies_malformed`. On paths that cannot reject
(queue drain, non-Desktop sources) a malformed envelope is kept verbatim as
ordinary text — user bytes are never silently dropped.

### Event payload shapes

`tool_status`, `approval_request`, `approval_request.flags`, and
Expand Down
1 change: 1 addition & 0 deletions docs/desktop-wire-fixtures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ codecs.
| `http_get.sessions.schedule.response.json` | `server.go handleSessions` (`GET /sessions?schedule_id=<id>`) | exact scheduled-task session filter. Each matching row carries persistent `schedule_id`; deleting the schedule configuration does not delete or rewrite the session. |
| `http_get.session.response.json` | `server.go handleGetSession` (`GET /sessions/{id}`) | lossless default session detail. Top-level `messages` remain the archive; `compaction_checkpoint` additively exposes the durable model-live state and its exclusive raw archive index. No new capability token is required: clients that ignore the additive field retain the existing archive semantics; consumers that opt into live-context metrics read the checkpoint explicitly. |
| `http_get.session.remote_timeline.response.json` | `server.go handleGetSession` (`GET /sessions/{id}?view=remote_timeline`) | capability-gated mobile projection of the archive. Returns a byte-bounded newest page with aligned `messages` / `message_meta`, absolute `start_index`, opaque `next_cursor`, `has_more`, and explicit `omitted_content_count`; it projects from top-level archived `messages`, not the model-live checkpoint. |
| `http_post.session_fork.response.json` | `conversation_context.go handleForkSession` (`POST /sessions/{id}/fork`) | fork response for `conversation_context_actions_v1`. `session_id` is the freshly generated fork id (shape-asserted, normalized in the test); `title` snapshots the source session's title. The side-chat route has no fixture of its own: its non-SSE response is a `RunAgentResult` and its SSE stream reuses the already-pinned per-request frames; the boundary, tool-free, and non-persistence contracts are exercised in `internal/daemon/conversation_context_test.go`. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Describe side chats as tool-enabled

This wire-contract index says the tests exercise a tool-free side-chat contract, but handleSideChat deliberately retains the normal registry and TestSideChatSSEEmitsApprovalRequestAndRunsTool proves an approval-requiring tool executes. Describing the endpoint as tool-free gives maintainers and Desktop consumers the opposite safety semantics; update this entry to say tool-enabled with the normal permission and approval flow.

Useful? React with 👍 / 👎.


### Quick-panel surfaces (POST request bodies + error responses)

Expand Down
3 changes: 2 additions & 1 deletion docs/desktop-wire-fixtures/http_get.status.response.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@
"agent_service_tier_v1",
"web_search_usage_v1",
"skill_install_recommendation_v1",
"work_plan_v1"
"work_plan_v1",
"conversation_context_actions_v1"
],
"memory": {
"provider": "disabled",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"session_id": "2026-08-17-a1b2c3d4e5f6",
"title": "Source title"
}
7 changes: 6 additions & 1 deletion internal/agent/inject_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ drain:
return out
}

// InjectedUserMessagePrefix marks a text-only injected-turn user message.
// Exported because the daemon's persistence path keys off it to locate each
// drained follow-up's head inside the joined text.
const InjectedUserMessagePrefix = "[New message from user]\n"

// buildInjectedUserMessage converts drained InjectedMessages into a single
// user-role Message ready to append. Returns (zero, false) when input is
// empty. When NO Files are present, emits a plain text content (preserving
Expand All @@ -70,7 +75,7 @@ func buildInjectedUserMessage(drained []InjectedMessage) (client.Message, bool)
// is byte-identical to the pre-refactor inject behavior.
return client.Message{
Role: "user",
Content: client.NewTextContent("[New message from user]\n" + combinedText),
Content: client.NewTextContent(InjectedUserMessagePrefix + combinedText),
}, true
}
blocks := make([]client.ContentBlock, 0, 1+len(files))
Expand Down
10 changes: 7 additions & 3 deletions internal/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -8887,9 +8887,13 @@ func looksLikeUnverifiedActionClaim(text string) bool {

// fabricatedToolCallPattern matches text that mimics tool call output format.
// Real tool calls go through the tool_calls API array — they never appear as text.
// Matches both old "I called" format (backward compat) and new <tool_exec> XML tags.
// XML branch requires exact attribute shape to avoid false-positives on code examples.
var fabricatedToolCallPattern = regexp.MustCompile(`(?s)(?:I called \w+\(.*?\)\.\s*\n\n(?:Result|Error):\s|<tool_exec tool="[^"]*" call_id="[^"]+">\n<input>.*?</input>\n<output status="(?:ok|error)">.*?</output>\n</tool_exec>)`)
// Matches the old "I called" format (backward compat), the <tool_exec> XML tags,
// and a bare paired <tool_call>name(args)</tool_call> block — a shape observed
// when the model invents a call syntax instead of using the API (e.g. a run
// whose history shows tool traffic it cannot reproduce). Each branch stays
// strict — exact attribute shape / paired tags around a function-call body —
// to avoid false-positives on code examples discussing these formats.
var fabricatedToolCallPattern = regexp.MustCompile(`(?s)(?:I called \w+\(.*?\)\.\s*\n\n(?:Result|Error):\s|<tool_exec tool="[^"]*" call_id="[^"]+">\n<input>.*?</input>\n<output status="(?:ok|error)">.*?</output>\n</tool_exec>|<tool_call>\s*\w+\(.*?\)\s*</tool_call>)`)

// looksLikeFabricatedToolCalls returns true if the model's text output contains
// what looks like fabricated tool call results. This is always a hallucination —
Expand Down
16 changes: 16 additions & 0 deletions internal/agent/loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2698,10 +2698,26 @@ func TestFabricatedToolCallDetection(t *testing.T) {
if !looksLikeFabricatedToolCalls(xml) {
t.Error("should detect XML format in text output")
}
// Bare paired <tool_call> block (observed side-chat fabrication shape)
bare := `Let me search for that.

<tool_call>web_search(query="kocoro daemon port")</tool_call>

The results show...`
if !looksLikeFabricatedToolCalls(bare) {
t.Error("should detect paired <tool_call> block")
}
// Normal text
if looksLikeFabricatedToolCalls("Here is the answer.") {
t.Error("should not flag normal text")
}
// Unpaired tag and prose mentioning the tag stay unflagged
if looksLikeFabricatedToolCalls("The <tool_call> tag is used by some models.") {
t.Error("should not flag unpaired tag mention")
}
if looksLikeFabricatedToolCalls("Wrap output in <tool_call>plain words</tool_call> markers.") {
t.Error("should not flag paired tags without a call-shaped body")
}
}

func TestPreambleSuppressedWithToolCalls(t *testing.T) {
Expand Down
5 changes: 5 additions & 0 deletions internal/daemon/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,10 @@ const (
// tool, Session.work_plan on GET /sessions/{id}, and the work_plan.updated
// bus event (emitted only after the snapshot's durable save).
CapWorkPlanV1 = "work_plan_v1"
// CapConversationContextActionsV1 advertises the Desktop-only local
// conversation context surface: complete-turn session forks and ephemeral,
// tool-free side chats seeded from a source transcript.
CapConversationContextActionsV1 = "conversation_context_actions_v1"
)

var Capabilities = []string{
Expand Down Expand Up @@ -371,6 +375,7 @@ var Capabilities = []string{
CapWebSearchUsageV1,
CapSkillInstallRecommendationV1,
CapWorkPlanV1,
CapConversationContextActionsV1,
}

// envelopeSenderFn lets tests substitute sendEnvelope without standing up a
Expand Down
225 changes: 225 additions & 0 deletions internal/daemon/conversation_context.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package daemon

import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"

"github.com/Kocoro-lab/ShanClaw/internal/agents"
"github.com/Kocoro-lab/ShanClaw/internal/client"
"github.com/Kocoro-lab/ShanClaw/internal/session"
)

const maxSideChatHistoryMessages = 100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the side-chat history cap at its declaration

This new max... small-integer cap has no declaration comment explaining the workload behind 100 messages, the client-visible 400 when it binds, or how an operator can override or lift it. Add the required rationale and override guidance at the declaration.

AGENTS.md reference: AGENTS.md:L31-L34

Useful? React with 👍 / 👎.


type forkSessionRequest struct {
Agent string `json:"agent,omitempty"`
TargetAgent *string `json:"target_agent,omitempty"`
MessageIndex int `json:"message_index"`
}

type forkSessionResponse struct {
SessionID string `json:"session_id"`
Title string `json:"title"`
}

type sideChatHistoryMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}

type sideChatRequest struct {
Agent string `json:"agent,omitempty"`
MessageIndex int `json:"message_index"`
Text string `json:"text"`
History []sideChatHistoryMessage `json:"history,omitempty"`
}

func (s *Server) handleForkSession(w http.ResponseWriter, r *http.Request) {
if s.deps == nil || s.deps.SessionCache == nil {
writeError(w, http.StatusInternalServerError, "daemon deps not configured")
return
}
id, body, ok := s.decodeConversationContextRequest(w, r)
if !ok {
return
}
sourceManager := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(body.Agent))
targetAgent := body.Agent
if body.TargetAgent != nil {
targetAgent = *body.TargetAgent
}
// GetOrCreateManager creates the target session directory, so a typo'd
// agent would silently file the fork where no UI ever lists it.
if !s.conversationAgentExists(targetAgent) {
writeErrorCode(w, http.StatusBadRequest, "agent_not_found",
fmt.Sprintf("agent not found: %s", targetAgent))
return
}
targetManager := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(targetAgent))
fork, err := sourceManager.ForkSessionInto(id, body.MessageIndex, targetManager)
if err != nil {
writeConversationContextError(w, id, err)
return
}
writeJSON(w, http.StatusCreated, forkSessionResponse{SessionID: fork.ID, Title: fork.Title})
}

func (s *Server) handleSideChat(w http.ResponseWriter, r *http.Request) {
if s.deps == nil || s.deps.SessionCache == nil {
writeError(w, http.StatusInternalServerError, "daemon deps not configured")
return
}
id := r.PathValue("id")
if err := ValidateSessionID(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var body sideChatRequest
if !decodeBody(w, r, &body) {
return
}
if body.Agent == "default" {
body.Agent = ""
}
if body.Agent != "" {
if err := agents.ValidateAgentName(body.Agent); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
}
// The ephemeral run executes AS this agent; failing here beats a late
// "agent not found" 500 out of RunAgent.
if !s.conversationAgentExists(body.Agent) {
writeErrorCode(w, http.StatusBadRequest, "agent_not_found",
fmt.Sprintf("agent not found: %s", body.Agent))
return
}
if strings.TrimSpace(body.Text) == "" {
writeError(w, http.StatusBadRequest, "text is required")
return
}
if len(body.History) > maxSideChatHistoryMessages {
writeError(w, http.StatusBadRequest, "side chat history is too long")
return
}
mgr := s.deps.SessionCache.GetOrCreateManager(s.deps.SessionCache.SessionsDir(body.Agent))
source, err := mgr.Load(id)
if err != nil {
writeConversationContextError(w, id, err)
return
}
index := body.MessageIndex
if index == 0 {
index = len(source.Messages)
}
history, err := mgr.CopyHistoryThrough(id, index)
if err != nil {
writeConversationContextError(w, id, err)
return
}
for _, item := range body.History {
role := strings.TrimSpace(item.Role)
content := strings.TrimSpace(item.Content)
if (role != "user" && role != "assistant") || content == "" {
writeError(w, http.StatusBadRequest, "side chat history must contain non-empty user/assistant messages")
return
}
history = append(history, client.Message{Role: role, Content: client.NewTextContent(content)})
}
// Side chats run with the normal tool registry and permission engine —
// identical capability to the primary conversation, only the lifecycle is
// ephemeral. Approvals flow over the per-request SSE stream like any
// /message run.
req := RunAgentRequest{
Text: body.Text,
Agent: body.Agent,
Source: "desktop",
CWD: source.CWD,
ProjectID: source.ProjectID,
NewSession: true,
Ephemeral: true,
BypassRouting: true,
SessionHistory: history,
SuppressBusEvents: true,
StickyContext: "This is a temporary side conversation branched from the primary conversation; its transcript is not saved. You have your normal tools — use them when they help answer. Do not modify or claim to modify the primary conversation itself.",
}
if err := req.Validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if strings.Contains(r.Header.Get("Accept"), "text/event-stream") {
s.handleMessageSSE(w, r, req)
return
}
handler := &httpEventHandler{}
result, err := RunAgent(r.Context(), s.deps, req, handler)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}

func (s *Server) decodeConversationContextRequest(w http.ResponseWriter, r *http.Request) (string, forkSessionRequest, bool) {
id := r.PathValue("id")
if err := ValidateSessionID(id); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return "", forkSessionRequest{}, false
}
var body forkSessionRequest
if !decodeBody(w, r, &body) {
return "", forkSessionRequest{}, false
}
if body.Agent == "default" {
body.Agent = ""
}
if body.Agent != "" {
if err := agents.ValidateAgentName(body.Agent); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return "", forkSessionRequest{}, false
}
}
if body.TargetAgent != nil {
target := *body.TargetAgent
if target == "default" {
target = ""
}
if target != "" {
if err := agents.ValidateAgentName(target); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return "", forkSessionRequest{}, false
}
}
body.TargetAgent = &target
}
return id, body, true
}

// conversationAgentExists reports whether a named agent is defined (user dir
// or _builtin fallback, mirroring agents.LoadAgent's resolution). The empty
// name is the default agent, which always exists.
func (s *Server) conversationAgentExists(name string) bool {
if name == "" {
return true
}
if _, err := os.Stat(filepath.Join(s.deps.AgentsDir, name, "AGENT.md")); err == nil {
return true
}
_, err := os.Stat(filepath.Join(s.deps.AgentsDir, "_builtin", name, "AGENT.md"))
return err == nil
}

func writeConversationContextError(w http.ResponseWriter, id string, err error) {
switch {
case errors.Is(err, os.ErrNotExist):
writeError(w, http.StatusNotFound, fmt.Sprintf("session %q not found", id))
case errors.Is(err, session.ErrMessageIndexOutOfRange), errors.Is(err, session.ErrIncompleteTurnBoundary):
writeError(w, http.StatusBadRequest, err.Error())
default:
writeError(w, http.StatusInternalServerError, err.Error())
}
}
Loading