355 lines
12 KiB
Go
355 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
acp "github.com/coder/acp-go-sdk"
|
|
)
|
|
|
|
// scriptState holds mutable state shared across script commands and sequence emitters.
|
|
type scriptState struct {
|
|
toolCallCounter int
|
|
lastToolID string
|
|
// monitorTools maps user-supplied Monitor taskID -> generated toolCallID,
|
|
// so e2e:monitor_event and e2e:monitor_end can reference an earlier
|
|
// e2e:monitor_start by name without the script needing to track IDs.
|
|
monitorTools map[string]acp.ToolCallId
|
|
}
|
|
|
|
// state is the process-wide script state. Use resetState() in tests
|
|
// to get a clean starting point instead of touching fields directly.
|
|
var state = &scriptState{monitorTools: map[string]acp.ToolCallId{}}
|
|
|
|
func resetState() {
|
|
state = &scriptState{monitorTools: map[string]acp.ToolCallId{}}
|
|
}
|
|
|
|
func nextToolID() acp.ToolCallId {
|
|
state.toolCallCounter++
|
|
id := fmt.Sprintf("mock_tool_%04d", state.toolCallCounter)
|
|
state.lastToolID = id
|
|
return acp.ToolCallId(id)
|
|
}
|
|
|
|
// --- Atomic emitters ---
|
|
|
|
// emitThinking emits a thinking block with random delay.
|
|
func emitThinking(e *emitter, model string) {
|
|
randomDelay(model)
|
|
e.thought("Analyzing the request and considering the best approach...")
|
|
}
|
|
|
|
// emitReadFile emits a Read tool call using a real workspace file.
|
|
func emitReadFile(e *emitter, model string) {
|
|
toolID := nextToolID()
|
|
f := randomFile()
|
|
snippet := readFileSnippet(f.absPath, 30)
|
|
randomDelay(model)
|
|
|
|
e.startTool(toolID, "Read "+f.relPath, acp.ToolKindRead,
|
|
map[string]any{"file_path": f.absPath},
|
|
acp.ToolCallLocation{Path: f.absPath})
|
|
|
|
randomDelay(model)
|
|
e.completeTool(toolID, map[string]any{"content": snippet})
|
|
}
|
|
|
|
// emitEditFile emits an Edit tool call with permission request.
|
|
func emitEditFile(e *emitter, model string) {
|
|
toolID := nextToolID()
|
|
f := randomFile()
|
|
oldStr, newStr := pickEditableFragment(f.absPath)
|
|
randomDelay(model)
|
|
|
|
input := map[string]any{
|
|
"file_path": f.absPath,
|
|
"old_string": oldStr,
|
|
"new_string": newStr,
|
|
}
|
|
|
|
e.startTool(toolID, "Edit "+f.relPath, acp.ToolKindEdit, input,
|
|
acp.ToolCallLocation{Path: f.absPath})
|
|
|
|
if e.requestPermission(toolID, "Edit "+f.relPath, acp.ToolKindEdit, input) {
|
|
e.completeTool(toolID, map[string]any{toolKeyResult: "File edited successfully: " + f.absPath})
|
|
} else {
|
|
e.completeTool(toolID, map[string]any{toolKeyResult: "Permission denied for Edit"})
|
|
e.text("Permission denied for Edit, skipping.")
|
|
}
|
|
}
|
|
|
|
// emitShellExec emits a Bash tool call with permission request.
|
|
func emitShellExec(e *emitter, model string) {
|
|
toolID := nextToolID()
|
|
randomDelay(model)
|
|
|
|
input := map[string]any{
|
|
"command": "go test ./...",
|
|
"description": "Run all tests",
|
|
}
|
|
|
|
e.startTool(toolID, "Run tests", acp.ToolKindExecute, input)
|
|
|
|
if e.requestPermission(toolID, "Run tests", acp.ToolKindExecute, input) {
|
|
e.completeTool(toolID, map[string]any{"output": "ok \tgithub.com/example/project\t0.042s\nPASS"})
|
|
} else {
|
|
e.completeTool(toolID, map[string]any{"output": "Permission denied"})
|
|
e.text("Permission denied for Bash, skipping.")
|
|
}
|
|
}
|
|
|
|
// emitCodeSearch emits a Grep tool call using real workspace file paths.
|
|
func emitCodeSearch(e *emitter, model string) {
|
|
toolID := nextToolID()
|
|
randomDelay(model)
|
|
|
|
searchPatterns := []string{"func ", "import ", "TODO", "return ", toolKeyError, "type "}
|
|
pattern := searchPatterns[state.toolCallCounter%len(searchPatterns)]
|
|
|
|
f := randomFile()
|
|
e.startTool(toolID, "Search for \""+pattern+"\"", acp.ToolKindSearch,
|
|
map[string]any{"pattern": pattern, "path": f.absPath})
|
|
|
|
randomDelay(model)
|
|
|
|
paths := randomFilePaths(3)
|
|
var results []string
|
|
for i, p := range paths {
|
|
results = append(results, fmt.Sprintf("%s:%d:%s found here", p, (i+1)*10, strings.TrimSpace(pattern)))
|
|
}
|
|
e.completeTool(toolID, map[string]any{"matches": strings.Join(results, "\n")})
|
|
}
|
|
|
|
// emitSubagent emits a claude-style subagent (Task) tool call plus a sibling
|
|
// search tool call.
|
|
func emitSubagent(e *emitter, model string) {
|
|
taskToolID := nextToolID()
|
|
randomDelay(model)
|
|
|
|
e.startSubagentTool(taskToolID,
|
|
"Explore codebase",
|
|
"Find all files and summarize the project structure",
|
|
"general-purpose")
|
|
|
|
randomDelay(model)
|
|
e.thought("Exploring the project structure...")
|
|
randomDelay(model)
|
|
|
|
paths := randomFilePaths(5)
|
|
e.text(fmt.Sprintf("Found %d files. The project structure looks well-organized.", len(paths)))
|
|
randomDelay(model)
|
|
|
|
// Sibling glob tool call (subagents do not nest over ACP)
|
|
childToolID := nextToolID()
|
|
e.startTool(childToolID, "Glob **/*", acp.ToolKindSearch,
|
|
map[string]any{"pattern": "**/*"})
|
|
randomDelay(model)
|
|
e.completeTool(childToolID, map[string]any{"files": strings.Join(paths, "\n")})
|
|
randomDelay(model)
|
|
|
|
e.text("Project structure analysis complete.")
|
|
randomDelay(model)
|
|
e.completeSubagentTool(taskToolID,
|
|
fmt.Sprintf("Subagent completed: Found %d files across the project.", len(paths)),
|
|
subagentResult{
|
|
agentID: "agent_dev_0001",
|
|
subagentType: "general-purpose",
|
|
durationMs: 1850,
|
|
totalTokens: 7421,
|
|
toolUseCount: 1,
|
|
})
|
|
}
|
|
|
|
// emitTodo emits a TodoWrite tool call.
|
|
func emitTodo(e *emitter, model string) {
|
|
toolID := nextToolID()
|
|
randomDelay(model)
|
|
|
|
e.startTool(toolID, "Update todo list", acp.ToolKindOther, map[string]any{
|
|
"todos": []map[string]any{
|
|
{"id": "1", "content": "Review code changes", "status": "in_progress"},
|
|
{"id": "2", "content": "Run tests", "status": "pending"},
|
|
{"id": "3", "content": "Update documentation", "status": "pending"},
|
|
},
|
|
})
|
|
|
|
randomDelay(model)
|
|
e.completeTool(toolID, map[string]any{toolKeyResult: "Todo list updated: 3 items (1 in progress, 2 pending)"})
|
|
}
|
|
|
|
// emitMermaidSequence emits a rich markdown message containing mermaid diagrams.
|
|
func emitMermaidSequence(e *emitter, model string) {
|
|
emitThinking(e, model)
|
|
randomDelay(model)
|
|
|
|
e.text("Here's an overview of the system architecture with diagrams:\n\n" +
|
|
"## System Flow\n\n" +
|
|
"The following flowchart shows the request processing pipeline:\n\n" +
|
|
"```mermaid\n" +
|
|
"flowchart TD\n" +
|
|
" A([Client Request]) --> B{Auth Check}\n" +
|
|
" B -->|Valid| C[API Gateway]\n" +
|
|
" B -->|Invalid| D[401 Unauthorized]\n" +
|
|
" C --> E[Load Balancer]\n" +
|
|
" E --> F[Service A]\n" +
|
|
" E --> G[Service B]\n" +
|
|
" F --> H[(Database)]\n" +
|
|
" G --> H\n" +
|
|
" H --> I[Response Builder]\n" +
|
|
" I --> J([Client Response])\n" +
|
|
"```\n\n" +
|
|
"## Sequence Diagram\n\n" +
|
|
"Here's how the authentication flow works between components:\n\n" +
|
|
"```mermaid\n" +
|
|
"sequenceDiagram\n" +
|
|
" participant U as User\n" +
|
|
" participant FE as Frontend\n" +
|
|
" participant API as API Server\n" +
|
|
" participant DB as Database\n" +
|
|
" U->>FE: Login Request\n" +
|
|
" FE->>API: POST /auth/login\n" +
|
|
" API->>DB: Verify Credentials\n" +
|
|
" DB-->>API: User Record\n" +
|
|
" API-->>FE: JWT Token\n" +
|
|
" FE-->>U: Redirect to Dashboard\n" +
|
|
"```\n\n" +
|
|
"### Key Points\n\n" +
|
|
"- All requests go through the **API Gateway** for rate limiting\n" +
|
|
"- Authentication uses **JWT tokens** with a 24h expiry\n" +
|
|
"- Services communicate via `gRPC` internally\n" +
|
|
"- Database connections use a **connection pool** (max 50)\n")
|
|
}
|
|
|
|
// emitMarkdownShowcase emits a rich multi-message demonstration of every
|
|
// rendered text/markdown type: headings, paragraphs (short and long), inline
|
|
// code, fenced code blocks, lists, tables, blockquotes, bold/italic, and hr.
|
|
func emitMarkdownShowcase(e *emitter, model string) {
|
|
randomDelay(model)
|
|
e.text(markdownHeadingsMsg())
|
|
randomDelay(model)
|
|
e.text(markdownLongParaMsg())
|
|
randomDelay(model)
|
|
e.text(markdownCodeBlocksMsg())
|
|
randomDelay(model)
|
|
e.text(markdownListsMsg())
|
|
randomDelay(model)
|
|
e.text(markdownTableBlockquoteMsg())
|
|
}
|
|
|
|
func markdownHeadingsMsg() string {
|
|
return "# Heading 1\n\n" +
|
|
"## Heading 2\n\n" +
|
|
"### Heading 3\n\n" +
|
|
"Short paragraph. Inline code looks like `const x = 42` in the middle of a sentence.\n\n" +
|
|
"Another short one: call `os.Exit(1)` to terminate, or use `fmt.Errorf(\"wrap: %w\", err)` for wrapping errors."
|
|
}
|
|
|
|
func markdownLongParaMsg() string {
|
|
return "## Long Paragraph\n\n" +
|
|
"This is a deliberately long paragraph to test line wrapping and reading comfort at various chat panel widths. " +
|
|
"When a user writes a prompt that requires careful reasoning, the agent first decomposes the request into sub-problems, " +
|
|
"then resolves each one in dependency order before synthesising a final answer. " +
|
|
"The approach mirrors how a senior engineer would tackle an unfamiliar codebase: read the entry points, " +
|
|
"trace the data flow, identify the invariants that must hold, and only then start making changes. " +
|
|
"Getting this sequence right matters because changes made without understanding the invariants tend to " +
|
|
"introduce subtle regressions that only surface under production load. This is exactly the kind of bug that is " +
|
|
"hardest to diagnose after the fact.\n\n" +
|
|
"A second long paragraph follows immediately to check spacing between blocks. " +
|
|
"The spacing should feel consistent with single-paragraph messages, neither too tight nor too loose. " +
|
|
"Inter-paragraph spacing is controlled by `margin-top` on `.markdown-body p + p`, " +
|
|
"which currently sits at `0.5em`: enough breathing room without pushing content off-screen on short viewports."
|
|
}
|
|
|
|
func markdownCodeBlocksMsg() string {
|
|
return "## Code Blocks\n\n" +
|
|
"TypeScript:\n\n" +
|
|
"```typescript\n" +
|
|
"interface Session {\n" +
|
|
" id: string;\n" +
|
|
" createdAt: Date;\n" +
|
|
" messages: Message[];\n" +
|
|
"}\n\n" +
|
|
"async function fetchSession(id: string): Promise<Session> {\n" +
|
|
" const res = await fetch(`/api/sessions/${id}`);\n" +
|
|
" if (!res.ok) throw new Error(`HTTP ${res.status}`);\n" +
|
|
" return res.json();\n" +
|
|
"}\n" +
|
|
"```\n\n" +
|
|
"Go:\n\n" +
|
|
"```go\n" +
|
|
"func (s *Service) CreateTask(ctx context.Context, req CreateTaskRequest) (*Task, error) {\n" +
|
|
"\tif req.Title == \"\" {\n" +
|
|
"\t\treturn nil, fmt.Errorf(\"title is required\")\n" +
|
|
"\t}\n" +
|
|
"\ttask := &Task{\n" +
|
|
"\t\tID: uuid.New().String(),\n" +
|
|
"\t\tTitle: req.Title,\n" +
|
|
"\t\tCreatedAt: time.Now(),\n" +
|
|
"\t}\n" +
|
|
"\treturn s.repo.Save(ctx, task)\n" +
|
|
"}\n" +
|
|
"```\n\n" +
|
|
"Shell:\n\n" +
|
|
"```bash\n" +
|
|
"# Build and run tests\n" +
|
|
"make -C apps/backend build && \\\n" +
|
|
" go test ./... -race -count=1 | tee /tmp/test.log\n" +
|
|
"```"
|
|
}
|
|
|
|
func markdownListsMsg() string {
|
|
return "## Lists\n\n" +
|
|
"Unordered:\n\n" +
|
|
"- First item with `inline code` in it\n" +
|
|
"- Second item: **bold text** and *italic text* inline\n" +
|
|
"- Third item with a longer description: this tests wrapping inside list items at narrow panel widths, " +
|
|
"which is a common layout scenario in split-pane editors\n" +
|
|
" - Nested item A\n" +
|
|
" - Nested item B\n\n" +
|
|
"Ordered:\n\n" +
|
|
"1. Read the spec\n" +
|
|
"2. Write a failing test\n" +
|
|
"3. Implement the feature\n" +
|
|
"4. Refactor until the code is clean\n" +
|
|
"5. Open a PR and request review"
|
|
}
|
|
|
|
func markdownTableBlockquoteMsg() string {
|
|
return "## Table\n\n" +
|
|
"| Command | Description | Example |\n" +
|
|
"|---------|-------------|----------|\n" +
|
|
"| `/slow` | Slow response | `/slow 10s` |\n" +
|
|
"| `/thinking` | Reasoning blocks | `/thinking` |\n" +
|
|
"| `/mermaid` | Mermaid diagram | `/mermaid` |\n" +
|
|
"| `/markdown` | This showcase | `/markdown` |\n\n" +
|
|
"---\n\n" +
|
|
"## Blockquote\n\n" +
|
|
"> Programs must be written for people to read, and only incidentally for machines to execute.\n" +
|
|
"> Harold Abelson\n\n" +
|
|
"> A second blockquote paragraph to verify spacing between consecutive quote blocks " +
|
|
"and to ensure the left-border styling extends for the full height of wrapped lines.\n\n" +
|
|
"---\n\n" +
|
|
"## Inline Styles\n\n" +
|
|
"**Bold**, *italic*, ~~strikethrough~~, and `inline code` all in one sentence. " +
|
|
"A path like `apps/web/app/globals.css` mid-sentence, then more prose after it. " +
|
|
"And a longer inline snippet: `color-mix(in oklch, var(--foreground) 10%, var(--muted))` " +
|
|
"should wrap cleanly without breaking the surrounding text flow."
|
|
}
|
|
|
|
// emitWebFetch emits a WebFetch tool call.
|
|
func emitWebFetch(e *emitter, model string) {
|
|
toolID := nextToolID()
|
|
randomDelay(model)
|
|
|
|
e.startTool(toolID, "Fetch API docs", acp.ToolKindFetch, map[string]any{
|
|
"url": "https://example.com/api/docs",
|
|
clarificationPromptKey: "Extract the API endpoints and their descriptions",
|
|
})
|
|
|
|
randomDelay(model)
|
|
e.completeTool(toolID, map[string]any{
|
|
"content": "API Documentation:\n- GET /api/v1/users - List all users\n- POST /api/v1/users - Create a new user\n- GET /api/v1/users/:id - Get user by ID\n- PUT /api/v1/users/:id - Update user\n- DELETE /api/v1/users/:id - Delete user",
|
|
})
|
|
}
|