Files
kandev/apps/backend/cmd/mock-agent/emitter.go
T

323 lines
12 KiB
Go

package main
import (
"context"
"fmt"
acp "github.com/coder/acp-go-sdk"
)
// sessionUpdater abstracts the ACP connection methods used by the emitter.
// The real acp.AgentSideConnection satisfies this; tests provide a mock.
type sessionUpdater interface {
SessionUpdate(ctx context.Context, n acp.SessionNotification) error
RequestPermission(ctx context.Context, p acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error)
}
// emitter wraps an ACP connection and session ID to provide
// convenient methods for streaming agent updates.
type emitter struct {
ctx context.Context
conn sessionUpdater
sid acp.SessionId
}
// text sends an agent text message update.
func (e *emitter) text(msg string) {
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.UpdateAgentMessageText(msg),
})
}
// thought sends an agent thinking/reasoning update.
func (e *emitter) thought(msg string) {
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.UpdateAgentThoughtText(msg),
})
}
// startTool announces a new tool call.
func (e *emitter) startTool(id acp.ToolCallId, title string, kind acp.ToolKind, input any, locs ...acp.ToolCallLocation) {
opts := []acp.ToolCallStartOpt{
acp.WithStartKind(kind),
acp.WithStartStatus(acp.ToolCallStatusPending),
acp.WithStartRawInput(input),
}
if len(locs) > 0 {
opts = append(opts, acp.WithStartLocations(locs))
}
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.StartToolCall(id, title, opts...),
})
}
// completeTool marks a tool call as completed with output.
func (e *emitter) completeTool(id acp.ToolCallId, output any) {
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.UpdateToolCall(id,
acp.WithUpdateStatus(acp.ToolCallStatusCompleted),
acp.WithUpdateRawOutput(output),
),
})
}
// plan sends an ACP Plan update with the provided entries.
func (e *emitter) plan(entries []acp.PlanEntry) {
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.UpdatePlan(entries...),
})
}
// monitorClaudeMeta builds the `_meta.claudeCode.toolName=Monitor` payload
// claude-agent-acp tags Monitor tool_call notifications with. Used by
// e2e:monitor_* directives to reproduce the wire format exactly so the
// kandev ACP adapter's Monitor recognizers fire the same way they do in
// production.
func monitorClaudeMeta() any {
return map[string]any{"claudeCode": map[string]any{"toolName": "Monitor"}}
}
// monitorClaudeMetaWithTask is like monitorClaudeMeta but also embeds a
// `toolResponse.taskId` field — claude-agent-acp emits this on the
// registration update.
func monitorClaudeMetaWithTask(taskID string) any {
return map[string]any{
"claudeCode": map[string]any{
"toolName": "Monitor",
"toolResponse": map[string]any{"taskId": taskID},
},
}
}
// startMonitorTool emits the initial Monitor tool_call (status=pending) and
// then a registration tool_call_update whose rawOutput banner carries the
// taskID. This reproduces the two-frame wire pattern the kandev adapter
// expects: a recognizable banner is the only signal it has that an apparent
// "completed" really means "registered".
//
// The acp-go-sdk does not expose `WithStartMeta` / `WithUpdateMeta` helpers
// at the time of writing, so we set Meta via a local option closure.
func (e *emitter) startMonitorTool(id acp.ToolCallId, taskID, command string) {
withStartMeta := func(meta any) acp.ToolCallStartOpt {
return func(tc *acp.SessionUpdateToolCall) { tc.Meta = toMetaMap(meta) }
}
withUpdateMeta := func(meta any) acp.ToolCallUpdateOpt {
return func(tu *acp.SessionToolCallUpdate) { tu.Meta = toMetaMap(meta) }
}
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.StartToolCall(id, "Monitor",
acp.WithStartKind(acp.ToolKindOther),
acp.WithStartStatus(acp.ToolCallStatusPending),
acp.WithStartRawInput(map[string]any{"command": command}),
withStartMeta(monitorClaudeMeta()),
),
})
banner := fmt.Sprintf("Monitor started (task %s, timeout 60000ms). You will be notified on each event.", taskID)
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.UpdateToolCall(id,
acp.WithUpdateStatus(acp.ToolCallStatusCompleted),
acp.WithUpdateRawOutput(banner),
withUpdateMeta(monitorClaudeMetaWithTask(taskID)),
),
})
}
// toMetaMap is a small adapter so the local meta builders can return `any`
// (matching the kandev-side recognizer signature) while the SDK's Meta field
// requires `map[string]any` specifically.
func toMetaMap(v any) map[string]any {
if v == nil {
return nil
}
if m, ok := v.(map[string]any); ok {
return m
}
return nil
}
// emitMonitorEvent reproduces the model-echoed task-notification envelope
// that fires when a real Monitor's stdout produces a line. The kandev
// adapter parses these out of agent_message_chunks and routes them back to
// the originating Monitor's tool_call card.
func (e *emitter) emitMonitorEvent(taskID, body string) {
envelope := fmt.Sprintf(
"Human: <task-notification>\n<task-id>%s</task-id>\n<event>%s</event>\n</task-notification>",
taskID, body,
)
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.UpdateAgentMessageText(envelope),
})
}
// endMonitorTool emits the terminal Monitor tool_call_update — the kandev
// adapter would normally synthesize this on its own at prompt-end, but tests
// drive it explicitly so they don't have to wait for the prompt to finish.
func (e *emitter) endMonitorTool(id acp.ToolCallId) {
withUpdateMeta := func(meta any) acp.ToolCallUpdateOpt {
return func(tu *acp.SessionToolCallUpdate) { tu.Meta = toMetaMap(meta) }
}
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.UpdateToolCall(id,
acp.WithUpdateStatus(acp.ToolCallStatusCompleted),
acp.WithUpdateRawOutput("Monitor exited"),
withUpdateMeta(monitorClaudeMeta()),
),
})
}
// subagent meta keys/values claude-agent-acp uses under
// `_meta.claudeCode.toolResponse`. Pulled out so goconst stays happy.
const (
subagentKeyStatus = "status"
subagentStatusCompleted = "completed"
subagentKeyDescription = "description"
subagentKeyPrompt = "prompt"
subagentKeySubagentType = "subagent_type"
)
// subagentClaudeMeta builds the `_meta.claudeCode.toolName=Agent` payload that
// claude-agent-acp tags subagent (Task) tool_call notifications with. The
// kandev ACP adapter recognizes subagents by this marker.
func subagentClaudeMeta() any {
return map[string]any{"claudeCode": map[string]any{"toolName": "Agent"}}
}
// subagentResult describes the result metadata claude-agent-acp reports for a
// finished subagent under `_meta.claudeCode.toolResponse`.
type subagentResult struct {
agentID string
subagentType string
durationMs int64
totalTokens int64
toolUseCount int
}
// subagentClaudeMetaWithResponse embeds a `toolResponse` block mirroring the
// real claude-agent-acp completion frame so the kandev adapter's
// EnrichSubagentResult populates every metric the UI renders.
func subagentClaudeMetaWithResponse(r subagentResult) any {
return map[string]any{
"claudeCode": map[string]any{
"toolName": "Agent",
"toolResponse": map[string]any{
"agentId": r.agentID,
"agentType": r.subagentType,
subagentKeyStatus: subagentStatusCompleted,
"totalDurationMs": r.durationMs,
"totalTokens": r.totalTokens,
"totalToolUseCount": r.toolUseCount,
},
},
}
}
// startSubagentTool emits the initial subagent tool_call (status=pending) in
// claude-agent-acp's wire shape: title "Task", kind Other, the Agent meta
// marker, and rawInput carrying description/prompt/subagent_type.
func (e *emitter) startSubagentTool(id acp.ToolCallId, description, prompt, subagentType string) {
withStartMeta := func(meta any) acp.ToolCallStartOpt {
return func(tc *acp.SessionUpdateToolCall) { tc.Meta = toMetaMap(meta) }
}
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.StartToolCall(id, "Task",
acp.WithStartKind(acp.ToolKindOther),
acp.WithStartStatus(acp.ToolCallStatusPending),
acp.WithStartRawInput(map[string]any{
subagentKeyDescription: description,
subagentKeyPrompt: prompt,
subagentKeySubagentType: subagentType,
}),
withStartMeta(subagentClaudeMeta()),
),
})
}
// completeSubagentTool emits the terminal subagent tool_call_update with the
// result text and the `toolResponse` metadata block.
func (e *emitter) completeSubagentTool(id acp.ToolCallId, resultText string, r subagentResult) {
withUpdateMeta := func(meta any) acp.ToolCallUpdateOpt {
return func(tu *acp.SessionToolCallUpdate) { tu.Meta = toMetaMap(meta) }
}
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.UpdateToolCall(id,
acp.WithUpdateStatus(acp.ToolCallStatusCompleted),
acp.WithUpdateRawOutput(resultText),
withUpdateMeta(subagentClaudeMetaWithResponse(r)),
),
})
}
// subagentChildMeta tags a tool call as a subagent's internal call via
// `_meta.claudeCode.parentToolUseId`, mirroring claude-agent-acp so the kandev
// adapter nests it under the parent Task card.
func subagentChildMeta(parentToolUseID acp.ToolCallId) any {
return map[string]any{"claudeCode": map[string]any{"parentToolUseId": string(parentToolUseID)}}
}
// startChildTool emits a tool_call attributed to a parent subagent (Task).
func (e *emitter) startChildTool(id, parent acp.ToolCallId, title string, kind acp.ToolKind, input any) {
withStartMeta := func(meta any) acp.ToolCallStartOpt {
return func(tc *acp.SessionUpdateToolCall) { tc.Meta = toMetaMap(meta) }
}
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.StartToolCall(id, title,
acp.WithStartKind(kind),
acp.WithStartStatus(acp.ToolCallStatusPending),
acp.WithStartRawInput(input),
withStartMeta(subagentChildMeta(parent)),
),
})
}
// completeChildTool completes a subagent child tool call, keeping the parent
// attribution on the terminal frame too.
func (e *emitter) completeChildTool(id, parent acp.ToolCallId, output any) {
withUpdateMeta := func(meta any) acp.ToolCallUpdateOpt {
return func(tu *acp.SessionToolCallUpdate) { tu.Meta = toMetaMap(meta) }
}
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
SessionId: e.sid,
Update: acp.UpdateToolCall(id,
acp.WithUpdateStatus(acp.ToolCallStatusCompleted),
acp.WithUpdateRawOutput(output),
withUpdateMeta(subagentChildMeta(parent)),
),
})
}
// requestPermission asks the client for permission to proceed with a tool call.
// Returns true if permission was granted, false otherwise.
func (e *emitter) requestPermission(toolCallID acp.ToolCallId, title string, kind acp.ToolKind, input any) bool {
resp, err := e.conn.RequestPermission(e.ctx, acp.RequestPermissionRequest{
SessionId: e.sid,
ToolCall: acp.ToolCallUpdate{
ToolCallId: toolCallID,
Title: acp.Ptr(title),
Kind: acp.Ptr(kind),
Status: acp.Ptr(acp.ToolCallStatusPending),
RawInput: input,
},
Options: []acp.PermissionOption{
{Kind: acp.PermissionOptionKindAllowOnce, Name: "Allow", OptionId: "allow"},
{Kind: acp.PermissionOptionKindRejectOnce, Name: "Reject", OptionId: "reject"},
},
})
if err != nil {
_, _ = fmt.Fprintf(logOutput, "mock-agent: permission request failed: %v\n", err)
return false
}
return resp.Outcome.Selected != nil && string(resp.Outcome.Selected.OptionId) == "allow"
}