Reduce MCP token usage
This commit is contained in:
@@ -134,16 +134,16 @@ func (h *blockingToolHost) ListProcesses(string, string) []ProcessInfo { return
|
||||
func (h *blockingToolHost) GetProcessStatus(string, string) (ProcessStatus, error) {
|
||||
return ProcessStatus{ProcessInfo: ProcessInfo{ID: "p_fast", Status: "running"}}, nil
|
||||
}
|
||||
func (h *blockingToolHost) GetProjectStatus(string) (ProjectStatus, error) {
|
||||
func (h *blockingToolHost) GetProjectStatus(string, bool) (ProjectStatus, error) {
|
||||
return ProjectStatus{}, nil
|
||||
}
|
||||
func (h *blockingToolHost) GetProcessOutput(string, string, string, int64) (ProcessOutput, error) {
|
||||
func (h *blockingToolHost) GetProcessOutput(string, ProcessOutputArgs) (ProcessOutput, error) {
|
||||
return ProcessOutput{}, nil
|
||||
}
|
||||
func (h *blockingToolHost) GetProcessRawOutput(string, string, int64) (RawOutput, error) {
|
||||
func (h *blockingToolHost) GetProcessRawOutput(string, RawOutputArgs) (RawOutput, error) {
|
||||
return RawOutput{}, nil
|
||||
}
|
||||
func (h *blockingToolHost) SearchOutput(string, string, string, string, int) (SearchResult, error) {
|
||||
func (h *blockingToolHost) SearchOutput(string, SearchOutputArgs) (SearchResult, error) {
|
||||
return SearchResult{}, nil
|
||||
}
|
||||
func (h *blockingToolHost) WaitForPattern(string, string, string, float64, string) (bool, string, error) {
|
||||
@@ -178,13 +178,13 @@ func (h *blockingToolHost) TimerList(string) ([]TimerInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (h *blockingToolHost) ScratchpadList() ([]scratchpad.Entry, error) { return nil, nil }
|
||||
func (h *blockingToolHost) ScratchpadRead(string) (string, string, error) {
|
||||
return "", "", nil
|
||||
func (h *blockingToolHost) ScratchpadRead(ScratchpadReadArgs) (ScratchpadReadResult, error) {
|
||||
return ScratchpadReadResult{}, nil
|
||||
}
|
||||
func (h *blockingToolHost) ScratchpadWrite(string, string, string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
func (h *blockingToolHost) ScratchpadAppend(string, string) error { return nil }
|
||||
func (h *blockingToolHost) ScratchpadDelete(string) error { return nil }
|
||||
func (h *blockingToolHost) WhoAmI(string) WhoAmI { return WhoAmI{} }
|
||||
func (h *blockingToolHost) WhoAmI(string, bool) WhoAmI { return WhoAmI{} }
|
||||
func (h *blockingToolHost) Help(string, string) HelpResponse { return HelpResponse{} }
|
||||
|
||||
@@ -3,6 +3,8 @@ package mcp
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/hjbdev/patterm/internal/scratchpad"
|
||||
)
|
||||
|
||||
// MCP protocol surface. The patterm server originally exposed each
|
||||
@@ -43,7 +45,7 @@ var serverInfo = map[string]any{
|
||||
// up as sub-agents and won't be tied into the patterm lifecycle.
|
||||
//
|
||||
// Keep this short — clients vary in how much they surface to the LLM.
|
||||
const serverInstructions = "You are already running INSIDE patterm; the `patterm` MCP server is connected over the same stdio MCP transport you use for any other MCP server. Use the MCP tools you see in tools/list — do NOT (a) try to launch `patterm` or `patterm mcp-stdio` yourself, (b) poke the Unix socket through perl / nc / socat / curl, or (c) shell out to `claude` / `codex` / `opencode` to start a peer. Any of those bypasses caller-identity and the new agent will land as a stray top-level tab instead of a child under you. Start with `whoami` for your role and the full tool list, then `help('topics')` for orientation. `spawn_agent` is the only correct way to start a sub-agent; `spawn_process` is for non-LLM commands; `list_processes` / `get_process_output` inspect them; `send_input` / `send_message` drive them. Whatever you spawn is yours to `close_process` when done. When you `send_message` a sub-agent, its reply comes back into YOUR pane as `[sub-agent:<name>] …`, not into the sub-agent's output — to wait for it, use `timer_fire_when_idle_any([sub_agent])` and then read your own pane; do NOT `wait_for_pattern` on the sub-agent, that will deadlock until timeout."
|
||||
const serverInstructions = "You are inside patterm. Use these MCP tools; do not launch patterm or poke its Unix socket yourself. Use spawn_agent for sub-agents, close spawned panes when done, and use timer_fire_when_idle_* instead of wait_for_pattern to wait for send_message replies."
|
||||
|
||||
// toolDescriptor is the shape returned by `tools/list`. inputSchema is
|
||||
// a JSON Schema object — we provide a minimal `{type: "object"}` schema
|
||||
@@ -76,37 +78,41 @@ func objectSchema(properties map[string]any, required []string) map[string]any {
|
||||
}
|
||||
|
||||
func stringProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": desc}
|
||||
_ = desc
|
||||
return map[string]any{"type": "string"}
|
||||
}
|
||||
|
||||
func numberProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "number", "description": desc}
|
||||
_ = desc
|
||||
return map[string]any{"type": "number"}
|
||||
}
|
||||
|
||||
func integerProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "integer", "description": desc}
|
||||
_ = desc
|
||||
return map[string]any{"type": "integer"}
|
||||
}
|
||||
|
||||
func booleanProp(desc string) map[string]any {
|
||||
return map[string]any{"type": "boolean", "description": desc}
|
||||
_ = desc
|
||||
return map[string]any{"type": "boolean"}
|
||||
}
|
||||
|
||||
func arrayOfStringsProp(desc string) map[string]any {
|
||||
_ = desc
|
||||
return map[string]any{
|
||||
"type": "array",
|
||||
"description": desc,
|
||||
"items": map[string]any{"type": "string"},
|
||||
"type": "array",
|
||||
"items": map[string]any{"type": "string"},
|
||||
}
|
||||
}
|
||||
|
||||
// toolCatalog is the full list advertised via tools/list. Descriptions
|
||||
// are intentionally short — clients are expected to fetch help() for
|
||||
// detail. Schemas mirror the param structs in tools.go.
|
||||
func toolCatalog() []toolDescriptor {
|
||||
return []toolDescriptor{
|
||||
func toolCatalog(role CallerRole) []toolDescriptor {
|
||||
tools := []toolDescriptor{
|
||||
{
|
||||
Name: "spawn_agent",
|
||||
Description: "Spawn a sub-agent from an agent preset and optionally seed it with initial instructions. This is the ONLY correct way to start a sub-agent under you — do not shell out to `claude` / `codex` / `opencode` and do not poke patterm's Unix socket via perl / nc / socat. Either bypasses caller identity and the new agent lands as a stray top-level tab instead of your child. Caller owns lifecycle: when the sub-agent's work is done (it reports back via send_message, or you no longer need it), call close_process on its process_id to free the pane and tear down the PTY. See help('spawning') and help('lifecycle').",
|
||||
Description: "Spawn a sub-agent from an agent preset.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"agent": stringProp("Preset name (e.g. \"claude\", \"codex\")."),
|
||||
"agent_instructions": stringProp("Initial prompt typed into the agent after it's ready."),
|
||||
@@ -115,14 +121,14 @@ func toolCatalog() []toolDescriptor {
|
||||
},
|
||||
{
|
||||
Name: "spawn_process",
|
||||
Description: "Spawn a process: a terminal, a process preset, or a freeform argv command. Caller owns lifecycle: when the process is no longer needed, call close_process to remove its entry (live children are SIGKILL'd first). See help('lifecycle').",
|
||||
Description: "Spawn a terminal, process preset, or argv command.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"kind": stringProp("\"terminal\" or \"command\"."),
|
||||
"preset": stringProp("Process preset name (mutually exclusive with argv)."),
|
||||
"argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "Argv vector for freeform commands."},
|
||||
"argv": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"name": stringProp("Display name for the pane."),
|
||||
"working_dir": stringProp("Working directory for the spawned process."),
|
||||
"env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "Extra environment variables."},
|
||||
"env": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}},
|
||||
"shell": booleanProp("Run argv through sh -lc."),
|
||||
}, nil),
|
||||
},
|
||||
@@ -188,7 +194,9 @@ func toolCatalog() []toolDescriptor {
|
||||
{
|
||||
Name: "get_project_status",
|
||||
Description: "One-shot orientation: project, caller, processes, scratchpads.",
|
||||
InputSchema: objectSchema(nil, nil),
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"include_tools": booleanProp("Include available_tools in caller metadata."),
|
||||
}, nil),
|
||||
},
|
||||
{
|
||||
Name: "get_process_output",
|
||||
@@ -197,6 +205,7 @@ func toolCatalog() []toolDescriptor {
|
||||
"process_id": stringProp("Target process id."),
|
||||
"mode": stringProp("\"grid\" (default) or \"stream\"."),
|
||||
"since_offset": integerProp("Watermark offset from a previous call."),
|
||||
"max_bytes": integerProp("Maximum content bytes to return."),
|
||||
}, []string{"process_id"}),
|
||||
},
|
||||
{
|
||||
@@ -205,6 +214,7 @@ func toolCatalog() []toolDescriptor {
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"process_id": stringProp("Target process id."),
|
||||
"since_offset": integerProp("Byte offset from a previous call."),
|
||||
"max_bytes": integerProp("Maximum content bytes to return."),
|
||||
}, []string{"process_id"}),
|
||||
},
|
||||
{
|
||||
@@ -214,12 +224,13 @@ func toolCatalog() []toolDescriptor {
|
||||
"process_id": stringProp("Target process id."),
|
||||
"pattern": stringProp("Regex pattern."),
|
||||
"kind": stringProp("\"rendered\" (default) or \"raw\"."),
|
||||
"limit": integerProp("Max matches (default 20)."),
|
||||
"limit": integerProp("Max matches (default 10)."),
|
||||
"max_bytes": integerProp("Max bytes per returned match line."),
|
||||
}, []string{"process_id", "pattern"}),
|
||||
},
|
||||
{
|
||||
Name: "wait_for_pattern",
|
||||
Description: "Block until pattern appears in the TARGET process's own output, or timeout elapses. Use this for waiting on text the target itself will emit (a shell prompt, a build's \"tests passed\" line, etc.). Anti-pattern: do NOT use this to wait for a sub-agent's reply to send_message — replies are routed into the CALLER's pane tagged `[sub-agent:<name>]`, not into the sub-agent's output, so this call will spin to timeout. For sub-agent coordination use `timer_fire_when_idle_any` and then read your own pane.",
|
||||
Description: "Block until pattern appears in the target process output.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"process_id": stringProp("Target process id."),
|
||||
"pattern": stringProp("Regex pattern."),
|
||||
@@ -238,18 +249,19 @@ func toolCatalog() []toolDescriptor {
|
||||
Name: "send_input",
|
||||
Description: "Type text, paste a block, or fire a named key into a process. Optional tail-after-send.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"process_id": stringProp("Target process id."),
|
||||
"kind": stringProp("\"text\", \"paste\", or \"key\"."),
|
||||
"text": stringProp("Text payload for kind=text/paste."),
|
||||
"key": stringProp("Named key for kind=key (e.g. \"enter\", \"escape\")."),
|
||||
"submit": booleanProp("Whether to append a submit keystroke."),
|
||||
"wait_ms": integerProp("After sending, wait this many ms before tailing."),
|
||||
"tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."),
|
||||
"process_id": stringProp("Target process id."),
|
||||
"kind": stringProp("\"text\", \"paste\", or \"key\"."),
|
||||
"text": stringProp("Text payload for kind=text/paste."),
|
||||
"key": stringProp("Named key for kind=key (e.g. \"enter\", \"escape\")."),
|
||||
"submit": booleanProp("Whether to append a submit keystroke."),
|
||||
"wait_ms": integerProp("After sending, wait this many ms before tailing."),
|
||||
"tail_mode": stringProp("\"none\" (default), \"stream\", or \"grid\"."),
|
||||
"tail_max_bytes": integerProp("Maximum bytes in returned tail."),
|
||||
}, []string{"process_id", "kind"}),
|
||||
},
|
||||
{
|
||||
Name: "send_message",
|
||||
Description: "Deliver a text message to another process as orchestrator-owned input. Fire-and-forget: returns immediately, without waiting for the recipient to read or act. If the recipient replies via send_message, that reply arrives in YOUR pane tagged `[sub-agent:<name>]` (child→parent) or `[orchestrator]` (parent→child) — NOT in the recipient's output. To wait for a sub-agent's reply, schedule `timer_fire_when_idle_any([sub_agent_id], body=…)` and then read your own pane when the timer fires. Do not `wait_for_pattern` on the recipient for a reply; it will deadlock.",
|
||||
Description: "Send a tagged message to a parent or child process.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"target_process_id": stringProp("Recipient process id."),
|
||||
"message": stringProp("Message body."),
|
||||
@@ -283,7 +295,7 @@ func toolCatalog() []toolDescriptor {
|
||||
},
|
||||
{
|
||||
Name: "timer_fire_when_idle_any",
|
||||
Description: "Canonical way to wait for a sub-agent to finish working: send_message the sub-agent, then schedule this with watched=[sub_agent_id]; when it fires, the reply is already sitting in your own pane tagged `[sub-agent:<name>]`. Schedules a timer that fires when any watched process enters idle (already-idle entries excluded), or when max_wait_seconds elapses.",
|
||||
Description: "Fire when any watched process becomes idle.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"watched": arrayOfStringsProp("Process ids to watch."),
|
||||
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
|
||||
@@ -294,7 +306,7 @@ func toolCatalog() []toolDescriptor {
|
||||
},
|
||||
{
|
||||
Name: "timer_fire_when_idle_all",
|
||||
Description: "Canonical way to wait for several sub-agents to finish working in parallel: send_message each one, then schedule this with watched=[…ids]; when it fires, each reply is in your own pane tagged `[sub-agent:<name>]`. Schedules a timer that fires when all watched processes are idle (already-idle entries count as satisfied), or when max_wait_seconds elapses.",
|
||||
Description: "Fire when all watched processes are idle.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"watched": arrayOfStringsProp("Process ids to watch."),
|
||||
"body": stringProp("Message delivered verbatim to the owning agent when the timer fires."),
|
||||
@@ -338,7 +350,9 @@ func toolCatalog() []toolDescriptor {
|
||||
Name: "scratchpad_read",
|
||||
Description: "Read a scratchpad entry, returning content and revision.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"name": stringProp("Scratchpad name."),
|
||||
"name": stringProp("Scratchpad name."),
|
||||
"offset": integerProp("Byte offset to start reading."),
|
||||
"max_bytes": integerProp("Maximum content bytes to return."),
|
||||
}, []string{"name"}),
|
||||
},
|
||||
{
|
||||
@@ -367,8 +381,10 @@ func toolCatalog() []toolDescriptor {
|
||||
},
|
||||
{
|
||||
Name: "whoami",
|
||||
Description: "Return the caller's identity, role, parent, project metadata, and available tools.",
|
||||
InputSchema: objectSchema(nil, nil),
|
||||
Description: "Return caller identity, role, parent, and project metadata.",
|
||||
InputSchema: objectSchema(map[string]any{
|
||||
"include_tools": booleanProp("Include full available tool list."),
|
||||
}, nil),
|
||||
},
|
||||
{
|
||||
Name: "help",
|
||||
@@ -378,6 +394,16 @@ func toolCatalog() []toolDescriptor {
|
||||
}, nil),
|
||||
},
|
||||
}
|
||||
if role != RoleSubAgent {
|
||||
return tools
|
||||
}
|
||||
filtered := tools[:0]
|
||||
for _, tool := range tools {
|
||||
if tool.Name != "spawn_agent" {
|
||||
filtered = append(filtered, tool)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// handleProtocolMethod handles MCP protocol-level methods. Returns
|
||||
@@ -416,7 +442,14 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
|
||||
return map[string]any{}, true, 0, "", nil
|
||||
|
||||
case "tools/list":
|
||||
return map[string]any{"tools": toolCatalog()}, true, 0, "", nil
|
||||
role := RoleOrchestrator
|
||||
s.mu.Lock()
|
||||
host := s.host
|
||||
s.mu.Unlock()
|
||||
if host != nil {
|
||||
role = host.CallerRole(callerID)
|
||||
}
|
||||
return map[string]any{"tools": toolCatalog(role)}, true, 0, "", nil
|
||||
|
||||
case "tools/call":
|
||||
var p struct {
|
||||
@@ -472,25 +505,12 @@ func (s *Server) handleProtocolMethod(callerID, method string, params json.RawMe
|
||||
return nil, false, 0, "", nil
|
||||
}
|
||||
|
||||
// wrapToolResult turns a structured tool result into an MCP tools/call
|
||||
// response. Plain strings (e.g. "ok") become text content; structured
|
||||
// values are JSON-encoded into a single text block and also exposed
|
||||
// under structuredContent so capable clients can read the shape.
|
||||
// wrapToolResult turns a tool result into an MCP tools/call response.
|
||||
// Structured values are exposed once under structuredContent; content
|
||||
// carries only a short model-readable summary to avoid duplicating
|
||||
// large JSON payloads into the transcript.
|
||||
func wrapToolResult(result any) map[string]any {
|
||||
var text string
|
||||
switch v := result.(type) {
|
||||
case nil:
|
||||
text = "ok"
|
||||
case string:
|
||||
text = v
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
text = fmt.Sprintf("%v", v)
|
||||
} else {
|
||||
text = string(b)
|
||||
}
|
||||
}
|
||||
text := summarizeToolResult(result)
|
||||
out := map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": text}},
|
||||
"isError": false,
|
||||
@@ -505,3 +525,70 @@ func wrapToolResult(result any) map[string]any {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func summarizeToolResult(result any) string {
|
||||
switch v := result.(type) {
|
||||
case nil:
|
||||
return "ok"
|
||||
case string:
|
||||
return v
|
||||
case ProcessInfo:
|
||||
return fmt.Sprintf("%s %s %s", v.ID, v.Kind, v.Status)
|
||||
case []ProcessInfo:
|
||||
return fmt.Sprintf("%d processes", len(v))
|
||||
case ProcessStatus:
|
||||
return fmt.Sprintf("%s %s %s", v.ID, v.Kind, v.Status)
|
||||
case ProjectStatus:
|
||||
return fmt.Sprintf("%d processes, %d scratchpads", len(v.Processes), len(v.Scratchpads))
|
||||
case ProcessOutput:
|
||||
return outputSummary(v.Mode, v.ContentBytes, v.Truncated, v.NewOffset)
|
||||
case RawOutput:
|
||||
return outputSummary("raw", v.ContentBytes, v.Truncated, v.NewOffset)
|
||||
case SearchResult:
|
||||
if v.Truncated {
|
||||
return fmt.Sprintf("%d matches (truncated)", len(v.Matches))
|
||||
}
|
||||
return fmt.Sprintf("%d matches", len(v.Matches))
|
||||
case SendInputResult:
|
||||
if v.Tail != nil {
|
||||
return "ok; tail included"
|
||||
}
|
||||
return "ok"
|
||||
case TimerHandle:
|
||||
return "timer " + v.ID
|
||||
case TimerFireWhenIdleResponse:
|
||||
if v.ID != "" {
|
||||
return fmt.Sprintf("%s timer %s", v.Status, v.ID)
|
||||
}
|
||||
return v.Status
|
||||
case []TimerInfo:
|
||||
return fmt.Sprintf("%d timers", len(v))
|
||||
case []scratchpad.Entry:
|
||||
return fmt.Sprintf("%d scratchpads", len(v))
|
||||
case ScratchpadReadResult:
|
||||
if v.Truncated {
|
||||
return fmt.Sprintf("%d/%d bytes from offset %d", v.ContentBytes, v.TotalBytes, v.Offset)
|
||||
}
|
||||
return fmt.Sprintf("%d bytes", v.ContentBytes)
|
||||
case WhoAmI:
|
||||
if v.ProcessID == "" {
|
||||
return string(v.Role)
|
||||
}
|
||||
return fmt.Sprintf("%s %s", v.ProcessID, v.Role)
|
||||
case HelpResponse:
|
||||
return fmt.Sprintf("help: %s", v.Topic)
|
||||
default:
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
|
||||
func outputSummary(mode string, bytes int, truncated bool, offset int64) string {
|
||||
s := fmt.Sprintf("%s output: %d bytes", mode, bytes)
|
||||
if offset > 0 {
|
||||
s += fmt.Sprintf(", offset %d", offset)
|
||||
}
|
||||
if truncated {
|
||||
s += " (truncated)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package mcp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -43,6 +44,9 @@ func TestInitializeReturnsCapabilities(t *testing.T) {
|
||||
if !ok || instructions == "" {
|
||||
t.Fatalf("instructions missing or wrong type: %+v", parsed.Result)
|
||||
}
|
||||
if len(instructions) > 320 {
|
||||
t.Fatalf("instructions too verbose: %d chars", len(instructions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitializedNotificationSuppressesResponse(t *testing.T) {
|
||||
@@ -74,6 +78,9 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) {
|
||||
if parsed.Error != nil {
|
||||
t.Fatalf("tools/list returned error: %+v", parsed.Error)
|
||||
}
|
||||
if len(resp) > 12000 {
|
||||
t.Fatalf("tools/list response too large: %d bytes", len(resp))
|
||||
}
|
||||
tools, ok := parsed.Result["tools"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("tools not array: %+v", parsed.Result)
|
||||
@@ -112,6 +119,27 @@ func TestToolsListReturnsConcreteSchemas(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapToolResultDoesNotDuplicateStructuredJSON(t *testing.T) {
|
||||
result := ProcessOutput{
|
||||
Content: strings.Repeat("x", 1024),
|
||||
Mode: "stream",
|
||||
NewOffset: 2048,
|
||||
ContentBytes: 1024,
|
||||
}
|
||||
wrapped := wrapToolResult(result)
|
||||
if wrapped["structuredContent"] == nil {
|
||||
t.Fatalf("structuredContent missing: %#v", wrapped)
|
||||
}
|
||||
content := wrapped["content"].([]map[string]any)
|
||||
text := content[0]["text"].(string)
|
||||
if strings.Contains(text, result.Content) {
|
||||
t.Fatalf("content duplicated structured payload: %q", text)
|
||||
}
|
||||
if !strings.Contains(text, "stream output") {
|
||||
t.Fatalf("summary text should identify output, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPingReturnsEmptyObject(t *testing.T) {
|
||||
s := &Server{}
|
||||
req := []byte(`{"jsonrpc":"2.0","id":3,"method":"ping"}`)
|
||||
|
||||
@@ -74,10 +74,10 @@ type ToolHost interface {
|
||||
// Inspection.
|
||||
ListProcesses(callerID, kindFilter string) []ProcessInfo
|
||||
GetProcessStatus(callerID, processID string) (ProcessStatus, error)
|
||||
GetProjectStatus(callerID string) (ProjectStatus, error)
|
||||
GetProcessOutput(callerID, processID, mode string, sinceOffset int64) (ProcessOutput, error)
|
||||
GetProcessRawOutput(callerID, processID string, sinceOffset int64) (RawOutput, error)
|
||||
SearchOutput(callerID, processID, pattern, kind string, limit int) (SearchResult, error)
|
||||
GetProjectStatus(callerID string, includeTools bool) (ProjectStatus, error)
|
||||
GetProcessOutput(callerID string, args ProcessOutputArgs) (ProcessOutput, error)
|
||||
GetProcessRawOutput(callerID string, args RawOutputArgs) (RawOutput, error)
|
||||
SearchOutput(callerID string, args SearchOutputArgs) (SearchResult, error)
|
||||
WaitForPattern(callerID, processID, pattern string, timeoutSeconds float64, scope string) (matched bool, snippet string, err error)
|
||||
GetProcessPorts(callerID, processID string) ([]PortSighting, error)
|
||||
|
||||
@@ -98,13 +98,13 @@ type ToolHost interface {
|
||||
|
||||
// Scratchpads.
|
||||
ScratchpadList() ([]scratchpad.Entry, error)
|
||||
ScratchpadRead(name string) (content string, revision string, err error)
|
||||
ScratchpadRead(args ScratchpadReadArgs) (ScratchpadReadResult, error)
|
||||
ScratchpadWrite(name, content, expectedRevision string) (revision string, err error)
|
||||
ScratchpadAppend(name, content string) error
|
||||
ScratchpadDelete(name string) error
|
||||
|
||||
// Meta.
|
||||
WhoAmI(callerID string) WhoAmI
|
||||
WhoAmI(callerID string, includeTools bool) WhoAmI
|
||||
Help(callerID, topic string) HelpResponse
|
||||
}
|
||||
|
||||
@@ -157,6 +157,10 @@ type ProjectStatus struct {
|
||||
Scratchpads []scratchpad.Entry `json:"scratchpads"`
|
||||
}
|
||||
|
||||
type ProjectStatusArgs struct {
|
||||
IncludeTools bool `json:"include_tools"`
|
||||
}
|
||||
|
||||
// ProjectMeta is the project root info echoed in many payloads.
|
||||
type ProjectMeta struct {
|
||||
Path string `json:"path"`
|
||||
@@ -166,23 +170,42 @@ type ProjectMeta struct {
|
||||
// ProcessOutput is the get_process_output payload. SPEC §7 enriches
|
||||
// the old read_output result with screen geometry + version.
|
||||
type ProcessOutput struct {
|
||||
Content string `json:"content"`
|
||||
Mode string `json:"mode"`
|
||||
NewOffset int64 `json:"new_offset,omitempty"`
|
||||
ActiveScreen string `json:"active_screen,omitempty"`
|
||||
Rows int `json:"rows,omitempty"`
|
||||
Cols int `json:"cols,omitempty"`
|
||||
Cursor Cursor `json:"cursor"`
|
||||
IdleMS int64 `json:"idle_ms,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ScreenVersion int64 `json:"screen_version,omitempty"`
|
||||
Content string `json:"content"`
|
||||
Mode string `json:"mode"`
|
||||
NewOffset int64 `json:"new_offset,omitempty"`
|
||||
ActiveScreen string `json:"active_screen,omitempty"`
|
||||
Rows int `json:"rows,omitempty"`
|
||||
Cols int `json:"cols,omitempty"`
|
||||
Cursor Cursor `json:"cursor"`
|
||||
IdleMS int64 `json:"idle_ms,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ScreenVersion int64 `json:"screen_version,omitempty"`
|
||||
ContentBytes int `json:"content_bytes,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
TruncatedBytes int `json:"truncated_bytes,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessOutputArgs struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Mode string `json:"mode"`
|
||||
SinceOffset int64 `json:"since_offset"`
|
||||
MaxBytes int `json:"max_bytes"`
|
||||
}
|
||||
|
||||
// RawOutput is the get_process_raw_output payload — ANSI preserved.
|
||||
type RawOutput struct {
|
||||
Content string `json:"content"`
|
||||
NewOffset int64 `json:"new_offset"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Content string `json:"content"`
|
||||
NewOffset int64 `json:"new_offset"`
|
||||
Status string `json:"status,omitempty"`
|
||||
ContentBytes int `json:"content_bytes,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
TruncatedBytes int `json:"truncated_bytes,omitempty"`
|
||||
}
|
||||
|
||||
type RawOutputArgs struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
SinceOffset int64 `json:"since_offset"`
|
||||
MaxBytes int `json:"max_bytes"`
|
||||
}
|
||||
|
||||
// SearchResult is search_output's payload.
|
||||
@@ -191,6 +214,14 @@ type SearchResult struct {
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
type SearchOutputArgs struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Pattern string `json:"pattern"`
|
||||
Kind string `json:"kind"`
|
||||
Limit int `json:"limit"`
|
||||
MaxBytes int `json:"max_bytes"`
|
||||
}
|
||||
|
||||
type SearchMatch struct {
|
||||
LineNo int `json:"line_no"`
|
||||
Text string `json:"text"`
|
||||
@@ -245,6 +276,7 @@ type TimerInfo struct {
|
||||
ID string `json:"timer_id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
BodyTruncated bool `json:"body_truncated,omitempty"`
|
||||
Kind string `json:"kind"` // "delay" | "idle_any" | "idle_all"
|
||||
Status string `json:"status"` // "pending" | "paused"
|
||||
OwnerID string `json:"owner_process_id"`
|
||||
@@ -281,13 +313,14 @@ type SpawnProcessArgs struct {
|
||||
// SendInputArgs is the input shape for send_input — covers text /
|
||||
// paste / key with the optional wait+tail tail-after-send.
|
||||
type SendInputArgs struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Kind string `json:"kind"` // "text" | "paste" | "key"
|
||||
Text string `json:"text"`
|
||||
Key string `json:"key"`
|
||||
Submit *bool `json:"submit"`
|
||||
WaitMS int `json:"wait_ms"`
|
||||
TailMode string `json:"tail_mode"` // "none" | "stream" | "grid"
|
||||
ProcessID string `json:"process_id"`
|
||||
Kind string `json:"kind"` // "text" | "paste" | "key"
|
||||
Text string `json:"text"`
|
||||
Key string `json:"key"`
|
||||
Submit *bool `json:"submit"`
|
||||
WaitMS int `json:"wait_ms"`
|
||||
TailMode string `json:"tail_mode"` // "none" | "stream" | "grid"
|
||||
TailMaxBytes int `json:"tail_max_bytes"`
|
||||
}
|
||||
|
||||
// SendInputResult is the return shape of send_input.
|
||||
@@ -306,6 +339,27 @@ type WhoAmI struct {
|
||||
AvailableTools []string `json:"available_tools"`
|
||||
}
|
||||
|
||||
type WhoAmIArgs struct {
|
||||
IncludeTools bool `json:"include_tools"`
|
||||
}
|
||||
|
||||
type ScratchpadReadArgs struct {
|
||||
Name string `json:"name"`
|
||||
Offset int `json:"offset"`
|
||||
MaxBytes int `json:"max_bytes"`
|
||||
}
|
||||
|
||||
type ScratchpadReadResult struct {
|
||||
Content string `json:"content"`
|
||||
Revision string `json:"revision"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
NextOffset int `json:"next_offset,omitempty"`
|
||||
ContentBytes int `json:"content_bytes,omitempty"`
|
||||
TotalBytes int `json:"total_bytes,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
TruncatedBytes int `json:"truncated_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// HelpResponse is the help return shape.
|
||||
type HelpResponse struct {
|
||||
Topic string `json:"topic"`
|
||||
@@ -507,61 +561,51 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
return st, 0, "", nil
|
||||
|
||||
case "get_project_status":
|
||||
ps, err := h.GetProjectStatus(callerID)
|
||||
var p ProjectStatusArgs
|
||||
_ = unmarshalParamsOptional(params, &p)
|
||||
ps, err := h.GetProjectStatus(callerID, p.IncludeTools)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return ps, 0, "", nil
|
||||
|
||||
case "get_process_output":
|
||||
var p struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Mode string `json:"mode"`
|
||||
SinceOffset int64 `json:"since_offset"`
|
||||
}
|
||||
var p ProcessOutputArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Mode == "" {
|
||||
p.Mode = "grid"
|
||||
}
|
||||
out, err := h.GetProcessOutput(callerID, p.ProcessID, p.Mode, p.SinceOffset)
|
||||
out, err := h.GetProcessOutput(callerID, p)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return out, 0, "", nil
|
||||
|
||||
case "get_process_raw_output":
|
||||
var p struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
SinceOffset int64 `json:"since_offset"`
|
||||
}
|
||||
var p RawOutputArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
out, err := h.GetProcessRawOutput(callerID, p.ProcessID, p.SinceOffset)
|
||||
out, err := h.GetProcessRawOutput(callerID, p)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
return out, 0, "", nil
|
||||
|
||||
case "search_output":
|
||||
var p struct {
|
||||
ProcessID string `json:"process_id"`
|
||||
Pattern string `json:"pattern"`
|
||||
Kind string `json:"kind"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
var p SearchOutputArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
if p.Limit <= 0 {
|
||||
p.Limit = 20
|
||||
p.Limit = 10
|
||||
}
|
||||
if p.Kind == "" {
|
||||
p.Kind = "rendered"
|
||||
}
|
||||
res, err := h.SearchOutput(callerID, p.ProcessID, p.Pattern, p.Kind, p.Limit)
|
||||
res, err := h.SearchOutput(callerID, p)
|
||||
if err != nil {
|
||||
return mapToolError(err)
|
||||
}
|
||||
@@ -731,17 +775,15 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
return entries, 0, "", nil
|
||||
|
||||
case "scratchpad_read":
|
||||
var p struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var p ScratchpadReadArgs
|
||||
if err := unmarshalParams(params, &p); err != nil {
|
||||
return nil, codeInvalidParams, err.Error(), nil
|
||||
}
|
||||
content, rev, err := h.ScratchpadRead(p.Name)
|
||||
res, err := h.ScratchpadRead(p)
|
||||
if err != nil {
|
||||
return nil, codeInternal, err.Error(), nil
|
||||
}
|
||||
return map[string]any{"content": content, "revision": rev}, 0, "", nil
|
||||
return res, 0, "", nil
|
||||
|
||||
case "scratchpad_write":
|
||||
var p struct {
|
||||
@@ -790,7 +832,9 @@ func callTool(h ToolHost, callerID, method string, params json.RawMessage) (any,
|
||||
return map[string]any{"ok": true}, 0, "", nil
|
||||
|
||||
case "whoami":
|
||||
return h.WhoAmI(callerID), 0, "", nil
|
||||
var p WhoAmIArgs
|
||||
_ = unmarshalParamsOptional(params, &p)
|
||||
return h.WhoAmI(callerID, p.IncludeTools), 0, "", nil
|
||||
|
||||
case "help":
|
||||
var p struct {
|
||||
|
||||
Reference in New Issue
Block a user