From 08f3bbc7aef8733857b51c92d75f3fd8356a7424 Mon Sep 17 00:00:00 2001 From: chaoyuepan Date: Sat, 18 Apr 2026 15:32:10 +0800 Subject: [PATCH 1/2] feat: add observability for sessions, tools, and agent execution (#11) Add metrics collection and structured logging via a new internal/metrics package. All observability is log-based with no external tracing dependencies. New metrics: - session.created, session.active_count, session.prompt_latency - request.total, request.latency, request.errors - tool.call_count, tool.call_duration, tool.call_errors - permission.denials - agent.exec_duration, agent.exec_failures - output.size_bytes - job.submitted/completed/failed/duration - ws.active_connections/subscribers, ws.dropped_subscribers Structured event logs for: session.prompt, session.result, session.error, tool.start, tool.end, job.failed, permission.denied --- .autoresearch/workflows/issue-11/log.md | 70 ++++++ internal/agent/agent.go | 47 +++- internal/gateway/server.go | 82 +++++- internal/gateway/stream_hub.go | 6 + internal/metrics/metrics.go | 320 ++++++++++++++++++++++++ internal/metrics/metrics_test.go | 214 ++++++++++++++++ 6 files changed, 734 insertions(+), 5 deletions(-) create mode 100644 .autoresearch/workflows/issue-11/log.md create mode 100644 internal/metrics/metrics.go create mode 100644 internal/metrics/metrics_test.go diff --git a/.autoresearch/workflows/issue-11/log.md b/.autoresearch/workflows/issue-11/log.md new file mode 100644 index 0000000..3078151 --- /dev/null +++ b/.autoresearch/workflows/issue-11/log.md @@ -0,0 +1,70 @@ +# Issue #11 实现日志 + +## 基本信息 +- Issue: #11 - feat: add observability for sessions, tools, and agent execution +- 项目: /Users/chaoyuepan/ai/imclaw +- 语言: go +- 开始时间: 2026-04-18 15:19:43 +- 标签: + +## 迭代记录 + +### 迭代 1 (2026-04-18) + +#### 实现摘要 + +为 IMClaw 添加了可观测性系统,通过结构化日志实现,不依赖外部 tracing 系统。 + +#### 新增文件 +- `internal/metrics/metrics.go` — 核心指标包,包含 Counter、Gauge、LatencyTracker、Registry 和 LogEvent +- `internal/metrics/metrics_test.go` — 指标包测试,覆盖率 96.7% + +#### 修改文件 +- `internal/gateway/server.go` — 添加了: + - RPC 请求计数、延迟追踪 (`request.total`, `request.latency`, `request.errors`) + - 会话创建、活跃计数 (`session.created`, `session.active_count`) + - Prompt 延迟追踪 (`session.prompt_latency`) + - 输出大小追踪 (`output.size_bytes`) + - 工具调用计数、持续时间、错误 (`tool.call_count`, `tool.call_duration`, `tool.call_errors`) + - Agent 执行失败计数 (`agent.exec_failures`) + - Job 提交/完成/失败追踪 + - WebSocket 连接活跃计数 + - 结构化事件日志 (`session.prompt`, `session.result`, `session.error`, `tool.start`, `tool.end`, `job.failed`) +- `internal/gateway/stream_hub.go` — 添加了: + - 订阅者活跃计数 (`ws.active_subscribers`) + - 慢订阅者丢弃计数 (`ws.dropped_subscribers`) +- `internal/agent/agent.go` — 添加了: + - Agent 执行延迟 (`agent.exec_duration`) + - 权限拒绝计数 (`permission.denials`) + - 权限拒绝事件日志 + - 流式请求的持续时间追踪 + +#### 测试结果 +- 所有测试通过 +- metrics 包覆盖率: 96.7% +- go vet 无错误 + +#### 关键指标 + +| 类别 | 指标名 | 类型 | 说明 | +|------|--------|------|------| +| Session | session.created | Counter | 会话创建次数 | +| Session | session.active_count | Gauge | 当前活跃会话数 | +| Session | session.prompt_latency | Latency | Prompt 处理延迟 | +| Request | request.total | Counter | RPC 请求总数 | +| Request | request.latency | Latency | RPC 请求延迟 | +| Request | request.errors | Counter | RPC 错误数 | +| Tool | tool.call_count | Counter | 工具调用次数 | +| Tool | tool.call_duration | Latency | 工具调用持续时间 | +| Tool | tool.call_errors | Counter | 工具调用错误数 | +| Permission | permission.denials | Counter | 权限拒绝次数 | +| Agent | agent.exec_duration | Latency | Agent 执行延迟 | +| Agent | agent.exec_failures | Counter | Agent 执行失败数 | +| Output | output.size_bytes | Counter | 输出总字节数 | +| Job | job.submitted | Counter | Job 提交数 | +| Job | job.completed | Counter | Job 完成数 | +| Job | job.failed | Counter | Job 失败数 | +| Job | job.duration | Latency | Job 执行延迟 | +| WS | ws.active_connections | Gauge | WebSocket 活跃连接数 | +| WS | ws.active_subscribers | Gauge | 活跃订阅者数 | +| WS | ws.dropped_subscribers | Counter | 丢弃的慢订阅者数 | diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 21e8f7c..ed5c19c 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -16,6 +16,7 @@ import ( "unicode/utf8" "github.com/creack/pty" + "github.com/smallnest/imclaw/internal/metrics" "github.com/smallnest/imclaw/internal/permission" ) @@ -447,9 +448,19 @@ func (a *ACPXAgent) doPrompt(ctx context.Context, sessionID, prompt string, opts log.Printf("[acpx] Sending prompt to session %s (%s, format=%s)", sessionID, policy.Summary(), format) log.Printf("[acpx] Prompt: %s", truncate(prompt, 200)) + start := time.Now() response, err := a.runCommand(ctx, timeout, args...) + metrics.Default().Latency(metrics.AgentExecDuration).Since(start) if err != nil { - return "", fmt.Errorf("%s", annotatePermissionError(err.Error(), policy)) + annotated := annotatePermissionError(err.Error(), policy) + if isPermissionError(err.Error()) { + metrics.Default().Counter(metrics.PermissionDenials).Inc() + metrics.LogEvent("permission.denied", sessionID, "", map[string]interface{}{ + "agent": a.agentType, + "policy": policy.Summary(), + }) + } + return "", fmt.Errorf("%s", annotated) } return response, nil } @@ -466,7 +477,31 @@ func (a *ACPXAgent) doPromptStream(ctx context.Context, sessionID, prompt string log.Printf("[acpx] Streaming prompt to session %s (%s)", sessionID, policy.Summary()) log.Printf("[acpx] Prompt: %s", truncate(prompt, 200)) - return a.runCommandStream(ctx, timeout, policy, args...) + streamStart := time.Now() + ch, err := a.runCommandStream(ctx, timeout, policy, args...) + if err != nil { + if isPermissionError(err.Error()) { + metrics.Default().Counter(metrics.PermissionDenials).Inc() + metrics.LogEvent("permission.denied", sessionID, "", map[string]interface{}{ + "agent": a.agentType, + "policy": policy.Summary(), + }) + } + metrics.Default().Counter(metrics.AgentExecFailures).Inc() + return nil, err + } + + // Wrap the channel to track duration when stream completes + wrappedCh := make(chan StreamChunk, 200) + go func() { + defer close(wrappedCh) + for chunk := range ch { + wrappedCh <- chunk + } + metrics.Default().Latency(metrics.AgentExecDuration).Since(streamStart) + }() + + return wrappedCh, nil } func resolvePromptPolicy(opts *PromptOptions) (*permission.ResolvedPolicy, error) { @@ -545,13 +580,17 @@ func annotatePermissionError(message string, policy *permission.ResolvedPolicy) if policy == nil { return message } - lower := strings.ToLower(message) - if strings.Contains(lower, "permission") || strings.Contains(lower, "exit status 5") || strings.Contains(lower, "refused") { + if isPermissionError(message) { return fmt.Sprintf("permission policy denied request (%s): %s", policy.Summary(), message) } return message } +func isPermissionError(message string) bool { + lower := strings.ToLower(message) + return strings.Contains(lower, "permission") || strings.Contains(lower, "exit status 5") || strings.Contains(lower, "refused") +} + // runCommandStream executes command and streams the output func (a *ACPXAgent) runCommandStream(ctx context.Context, timeout int, policy *permission.ResolvedPolicy, args ...string) (<-chan StreamChunk, error) { if timeout == 0 { diff --git a/internal/gateway/server.go b/internal/gateway/server.go index b2cb1c7..9eb1e86 100644 --- a/internal/gateway/server.go +++ b/internal/gateway/server.go @@ -16,6 +16,7 @@ import ( "github.com/smallnest/imclaw/internal/agent" "github.com/smallnest/imclaw/internal/event" "github.com/smallnest/imclaw/internal/job" + "github.com/smallnest/imclaw/internal/metrics" "github.com/smallnest/imclaw/internal/session" ) @@ -471,6 +472,7 @@ func (s *Server) handleJobsAPI(w http.ResponseWriter, r *http.Request) { timeout := time.Duration(req.Timeout) * time.Second submittedJob := s.jobMgr.Submit(req.Prompt, req.AgentName, timeout) + metrics.Default().Counter(metrics.JobSubmitted).Inc() // Start executing the job in background go job.ExecuteJob(context.Background(), s.jobMgr, submittedJob.ID, s.executeJobPrompt) @@ -537,6 +539,8 @@ func (s *Server) handleJobDetailAPI(w http.ResponseWriter, r *http.Request) { // executeJobPrompt executes a job prompt using the agent manager func (s *Server) executeJobPrompt(ctx context.Context, prompt string, logFn func(level, msg string)) (string, error) { + execStart := time.Now() + // Create a temporary session for this job agentType := "acpx" ag := s.agentMgr.GetOrCreate(agentType) @@ -556,9 +560,19 @@ func (s *Server) executeJobPrompt(ctx context.Context, prompt string, logFn func }) if err != nil { logFn("error", fmt.Sprintf("Execution failed: %v", err)) + metrics.Default().Counter(metrics.JobFailed).Inc() + metrics.Default().Counter(metrics.AgentExecFailures).Inc() + metrics.LogEvent("job.failed", sessionID, "", map[string]interface{}{ + "agent": agentType, + "error": truncate(err.Error(), 200), + }) return "", err } + metrics.Default().Latency(metrics.JobDuration).Since(execStart) + metrics.Default().Counter(metrics.JobCompleted).Inc() + metrics.Default().Counter(metrics.OutputSize).Add(int64(len(response))) + logFn("info", "Execution completed successfully") return response, nil } @@ -583,8 +597,17 @@ func (s *Server) handleJSONRPC(w http.ResponseWriter, r *http.Request) { return } + start := time.Now() + resp := s.handleRPCRequest("", &req) + + metrics.Default().Counter(metrics.RequestTotal).Inc() + metrics.Default().Latency(metrics.RequestLatency).Since(start) + if resp.Error != nil { + metrics.Default().Counter(metrics.RequestErrors).Inc() + } + w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(s.handleRPCRequest("", &req)) + _ = json.NewEncoder(w).Encode(resp) } func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { @@ -611,6 +634,7 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { s.connectionsMu.Lock() s.connections[wsConn.ID] = wsConn s.connectionsMu.Unlock() + metrics.Default().Gauge(metrics.WSConnections).Inc() _ = wsConn.SendJSON(JSONRPCRequest{ JSONRPC: "2.0", @@ -662,6 +686,7 @@ func (s *Server) handleWSMessages(conn *WSConnection) { s.connectionsMu.Lock() delete(s.connections, conn.ID) s.connectionsMu.Unlock() + metrics.Default().Gauge(metrics.WSConnections).Dec() }() _ = conn.SetReadDeadline(time.Now().Add(wsPongWait)) @@ -756,6 +781,11 @@ func (s *Server) handleRPCRequest(connID string, req *JSONRPCRequest) *JSONRPCRe } func (s *Server) handleAsk(connID string, req *JSONRPCRequest) *JSONRPCResponse { + askStart := time.Now() + defer func() { + metrics.Default().Latency(metrics.SessionPromptLatency).Since(askStart) + }() + params, ok := req.Params.(map[string]interface{}) if !ok { return invalidParams(req.ID) @@ -881,6 +911,11 @@ func (s *Server) relaySubscription(conn *WSConnection, sessionID string, ch <-ch } func (s *Server) handleAskStream(conn *WSConnection, req *JSONRPCRequest) { + streamStart := time.Now() + defer func() { + metrics.Default().Latency(metrics.SessionPromptLatency).Since(streamStart) + }() + params, ok := req.Params.(map[string]interface{}) if !ok { _ = conn.SendJSON(invalidParams(req.ID)) @@ -925,8 +960,33 @@ func (s *Server) handleAskStream(conn *WSConnection, req *JSONRPCRequest) { parser := event.NewParser() sawNativeEvents := false sawErrorEvent := false + toolStartTimes := make(map[string]time.Time) // tool name -> start time publishAndSend := func(evt agent.Event) { + // Track tool call metrics + switch evt.Type { + case agent.TypeToolStart: + toolStartTimes[evt.Name] = time.Now() + metrics.Default().Counter(metrics.ToolCallCount).Inc() + metrics.LogEvent("tool.start", sess.ID, req.ID, map[string]interface{}{ + "tool_name": evt.Name, + }) + case agent.TypeToolEnd: + if start, ok := toolStartTimes[evt.Name]; ok { + metrics.Default().Latency(metrics.ToolCallDuration).Since(start) + delete(toolStartTimes, evt.Name) + } + metrics.LogEvent("tool.end", sess.ID, req.ID, map[string]interface{}{ + "tool_name": evt.Name, + "input_size": len(evt.Input), + "output_size": len(evt.Output), + }) + case agent.TypeError: + if evt.Name != "" { + metrics.Default().Counter(metrics.ToolCallErrors).Inc() + } + } + s.recordEvent(sess.ID, req.ID, evt) // Fan-out to all hub subscribers. s.hub.Publish(sess.ID, HubEvent{Event: evt}) @@ -1039,6 +1099,11 @@ func (s *Server) recordPrompt(sessionID, requestID, prompt string) { if sess, ok := s.sessionMgr.RecordPrompt(defaultSessionChannel, sessionID, requestID, prompt); ok { s.broadcastSession(sess) s.broadcastActivity(sess.ID, sess.Activity[len(sess.Activity)-1]) + metrics.Default().Counter(metrics.SessionCreated).Inc() + metrics.Default().Gauge(metrics.SessionActive).Set(int64(len(s.sessionMgr.Summaries()))) + metrics.LogEvent("session.prompt", sessionID, requestID, map[string]interface{}{ + "prompt_length": len(prompt), + }) } } @@ -1053,6 +1118,10 @@ func (s *Server) recordResult(sessionID, requestID, content string) { if sess, ok := s.sessionMgr.RecordResult(defaultSessionChannel, sessionID, requestID, content); ok { s.broadcastSession(sess) s.broadcastActivity(sess.ID, sess.Activity[len(sess.Activity)-1]) + metrics.Default().Counter(metrics.OutputSize).Add(int64(len(content))) + metrics.LogEvent("session.result", sessionID, requestID, map[string]interface{}{ + "output_length": len(content), + }) } } @@ -1060,6 +1129,10 @@ func (s *Server) recordError(sessionID, requestID, message string) { if sess, ok := s.sessionMgr.RecordError(defaultSessionChannel, sessionID, requestID, message); ok { s.broadcastSession(sess) s.broadcastActivity(sess.ID, sess.Activity[len(sess.Activity)-1]) + metrics.Default().Counter(metrics.AgentExecFailures).Inc() + metrics.LogEvent("session.error", sessionID, requestID, map[string]interface{}{ + "error_message": truncate(message, 200), + }) } } @@ -1272,6 +1345,13 @@ func resolveSessionID(connID, specifiedSessionID string) string { return "default" } +func truncate(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + func invalidParams(id string) *JSONRPCResponse { return &JSONRPCResponse{JSONRPC: "2.0", ID: id, Error: &JSONRPCError{Code: -32602, Message: "Invalid params"}} } diff --git a/internal/gateway/stream_hub.go b/internal/gateway/stream_hub.go index a5cc955..e4ca71f 100644 --- a/internal/gateway/stream_hub.go +++ b/internal/gateway/stream_hub.go @@ -5,6 +5,7 @@ import ( "sync" "github.com/smallnest/imclaw/internal/agent" + "github.com/smallnest/imclaw/internal/metrics" ) const ( @@ -63,6 +64,7 @@ func (h *StreamHub) Subscribe(sessionID, subscriberID string) <-chan HubEvent { h.subscribers[sessionID] = make(map[string]chan HubEvent) } h.subscribers[sessionID][subscriberID] = ch + metrics.Default().Gauge(metrics.WSSubscribers).Inc() return ch } @@ -78,6 +80,7 @@ func (h *StreamHub) Unsubscribe(sessionID, subscriberID string) { if ch, exists := subs[subscriberID]; exists { delete(subs, subscriberID) close(ch) + metrics.Default().Gauge(metrics.WSSubscribers).Dec() } if len(subs) == 0 { delete(h.subscribers, sessionID) @@ -93,6 +96,7 @@ func (h *StreamHub) UnsubscribeAll(subscriberID string) { if ch, exists := subs[subscriberID]; exists { delete(subs, subscriberID) close(ch) + metrics.Default().Gauge(metrics.WSSubscribers).Dec() } if len(subs) == 0 { delete(h.subscribers, sessionID) @@ -127,6 +131,8 @@ func (h *StreamHub) Publish(sessionID string, evt HubEvent) { log.Printf("[stream-hub] Dropping slow subscriber %s for session %s", subID, sessionID) delete(subs, subID) close(ch) + metrics.Default().Counter(metrics.WSDroppedSubs).Inc() + metrics.Default().Gauge(metrics.WSSubscribers).Dec() } } if len(subs) == 0 { diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..35c599c --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,320 @@ +// Package metrics provides lightweight observability for IMClaw via structured logs. +// +// All metrics are emitted through Go's standard log package with a consistent +// [metrics] prefix and structured key=value pairs, making them grep-friendly +// and easy to feed into log aggregation pipelines. +package metrics + +import ( + "log" + "sort" + "sync" + "sync/atomic" + "time" +) + +// ---- Counters ---- + +// Counter is an atomically incremented metric counter. +type Counter struct { + name string + count atomic.Int64 +} + +// Inc increments the counter by 1. +func (c *Counter) Inc() { + v := c.count.Add(1) + log.Printf("[metrics] counter %s=%d", c.name, v) +} + +// Add increments the counter by n. +func (c *Counter) Add(n int64) { + v := c.count.Add(n) + log.Printf("[metrics] counter %s=%d delta=%d", c.name, v, n) +} + +// Value returns the current counter value. +func (c *Counter) Value() int64 { + return c.count.Load() +} + +// ---- Latency Tracker ---- + +// LatencyTracker measures duration distributions for named operations. +type LatencyTracker struct { + name string + mu sync.Mutex + samples []time.Duration +} + +// Observe records a duration and emits a structured log line. +func (lt *LatencyTracker) Observe(d time.Duration) { + lt.mu.Lock() + lt.samples = append(lt.samples, d) + lt.mu.Unlock() + + log.Printf("[metrics] latency %s duration_ms=%.2f", lt.name, float64(d)/float64(time.Millisecond)) +} + +// Since returns a duration from the given start time. It is a convenience +// wrapper intended for one-line usage: defer tracker.Since(time.Now()) +func (lt *LatencyTracker) Since(start time.Time) { + lt.Observe(time.Since(start)) +} + +// Summary returns aggregate statistics (count, min, max, avg, p50, p95, p99). +// Returns zero values if no samples have been recorded. +func (lt *LatencyTracker) Summary() LatencySummary { + lt.mu.Lock() + samples := make([]time.Duration, len(lt.samples)) + copy(samples, lt.samples) + lt.mu.Unlock() + + return computeSummary(lt.name, samples) +} + +// LatencySummary holds aggregate latency statistics. +type LatencySummary struct { + Name string + Count int + Min time.Duration + Max time.Duration + Avg time.Duration + P50 time.Duration + P95 time.Duration + P99 time.Duration + Samples []time.Duration +} + +// ---- Gauge ---- + +// Gauge tracks a point-in-time integer value. +type Gauge struct { + name string + value atomic.Int64 +} + +// Set updates the gauge value. +func (g *Gauge) Set(v int64) { + g.value.Store(v) + log.Printf("[metrics] gauge %s=%d", g.name, v) +} + +// Inc increments the gauge by 1. +func (g *Gauge) Inc() int64 { + v := g.value.Add(1) + log.Printf("[metrics] gauge %s=%d", g.name, v) + return v +} + +// Dec decrements the gauge by 1. +func (g *Gauge) Dec() int64 { + v := g.value.Add(-1) + log.Printf("[metrics] gauge %s=%d", g.name, v) + return v +} + +// Value returns the current gauge value. +func (g *Gauge) Value() int64 { + return g.value.Load() +} + +// ---- Registry ---- + +// Registry groups named metrics for a subsystem. +type Registry struct { + mu sync.Mutex + counters map[string]*Counter + latency map[string]*LatencyTracker + gauges map[string]*Gauge +} + +// NewRegistry creates a new metrics registry. +func NewRegistry() *Registry { + return &Registry{ + counters: make(map[string]*Counter), + latency: make(map[string]*LatencyTracker), + gauges: make(map[string]*Gauge), + } +} + +// Counter returns (or creates) a counter by name. +func (r *Registry) Counter(name string) *Counter { + r.mu.Lock() + defer r.mu.Unlock() + if c, ok := r.counters[name]; ok { + return c + } + c := &Counter{name: name} + r.counters[name] = c + return c +} + +// Latency returns (or creates) a latency tracker by name. +func (r *Registry) Latency(name string) *LatencyTracker { + r.mu.Lock() + defer r.mu.Unlock() + if lt, ok := r.latency[name]; ok { + return lt + } + lt := &LatencyTracker{name: name} + r.latency[name] = lt + return lt +} + +// Gauge returns (or creates) a gauge by name. +func (r *Registry) Gauge(name string) *Gauge { + r.mu.Lock() + defer r.mu.Unlock() + if g, ok := r.gauges[name]; ok { + return g + } + g := &Gauge{name: name} + r.gauges[name] = g + return g +} + +// Snapshot returns a point-in-time snapshot of all metrics. +func (r *Registry) Snapshot() Snapshot { + r.mu.Lock() + defer r.mu.Unlock() + + snap := Snapshot{ + Counters: make(map[string]int64, len(r.counters)), + Gauges: make(map[string]int64, len(r.gauges)), + Latency: make(map[string]LatencySummary, len(r.latency)), + } + for name, c := range r.counters { + snap.Counters[name] = c.Value() + } + for name, g := range r.gauges { + snap.Gauges[name] = g.Value() + } + for name, lt := range r.latency { + snap.Latency[name] = lt.Summary() + } + return snap +} + +// Snapshot is a point-in-time view of all registry metrics. +type Snapshot struct { + Counters map[string]int64 + Gauges map[string]int64 + Latency map[string]LatencySummary +} + +// ---- Global default registry ---- + +var defaultRegistry = NewRegistry() + +// Default returns the global default metrics registry. +func Default() *Registry { + return defaultRegistry +} + +// Predefined metric names following dashboard-friendly conventions. +// Naming: . +const ( + // Session metrics + SessionCreated = "session.created" + SessionDeleted = "session.deleted" + SessionActive = "session.active_count" + SessionPromptLatency = "session.prompt_latency" + + // Request metrics + RequestTotal = "request.total" + RequestLatency = "request.latency" + RequestErrors = "request.errors" + + // Tool metrics + ToolCallCount = "tool.call_count" + ToolCallDuration = "tool.call_duration" + ToolCallErrors = "tool.call_errors" + + // Permission metrics + PermissionDenials = "permission.denials" + + // Agent metrics + AgentExecDuration = "agent.exec_duration" + AgentExecFailures = "agent.exec_failures" + + // Output metrics + OutputSize = "output.size_bytes" + + // Job metrics + JobSubmitted = "job.submitted" + JobCompleted = "job.completed" + JobFailed = "job.failed" + JobDuration = "job.duration" + + // Connection metrics + WSConnections = "ws.active_connections" + WSSubscribers = "ws.active_subscribers" + WSDroppedSubs = "ws.dropped_subscribers" +) + +// ---- Event logging helpers ---- + +// LogEvent emits a structured event log line for key operational events. +func LogEvent(event, sessionID, requestID string, extra map[string]interface{}) { + pairs := make([]interface{}, 0, 2+len(extra)*2) + pairs = append(pairs, "event", event) + if sessionID != "" { + pairs = append(pairs, "session_id", sessionID) + } + if requestID != "" { + pairs = append(pairs, "request_id", requestID) + } + for k, v := range extra { + pairs = append(pairs, k, v) + } + log.Printf("[metrics] event "+repeatFormat(len(pairs)/2), pairs...) +} + +func repeatFormat(n int) string { + const pair = " %s=%v" + result := "" + for i := 0; i < n; i++ { + result += pair + } + return result +} + +// ---- Internal helpers ---- + +func computeSummary(name string, samples []time.Duration) LatencySummary { + s := LatencySummary{ + Name: name, + Count: len(samples), + Samples: samples, + } + if len(samples) == 0 { + return s + } + + // Sort a copy to compute percentiles + sorted := make([]time.Duration, len(samples)) + copy(sorted, samples) + sort.Sort(sortableDurations(sorted)) + + n := len(sorted) + s.Min = sorted[0] + s.Max = sorted[n-1] + + var total time.Duration + for _, d := range sorted { + total += d + } + s.Avg = total / time.Duration(n) + s.P50 = sorted[n*50/100] + s.P95 = sorted[n*95/100] + s.P99 = sorted[n*99/100] + + return s +} + +type sortableDurations []time.Duration + +func (d sortableDurations) Len() int { return len(d) } +func (d sortableDurations) Less(i, j int) bool { return d[i] < d[j] } +func (d sortableDurations) Swap(i, j int) { d[i], d[j] = d[j], d[i] } diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..db84e8c --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,214 @@ +package metrics + +import ( + "testing" + "time" +) + +func TestCounterInc(t *testing.T) { + r := NewRegistry() + c := r.Counter("test.counter") + + if v := c.Value(); v != 0 { + t.Fatalf("expected initial value 0, got %d", v) + } + c.Inc() + if v := c.Value(); v != 1 { + t.Fatalf("expected 1 after Inc, got %d", v) + } +} + +func TestCounterAdd(t *testing.T) { + r := NewRegistry() + c := r.Counter("test.counter_add") + + c.Add(5) + if v := c.Value(); v != 5 { + t.Fatalf("expected 5 after Add(5), got %d", v) + } + c.Add(3) + if v := c.Value(); v != 8 { + t.Fatalf("expected 8 after Add(3), got %d", v) + } +} + +func TestCounterRegistryReturnsSame(t *testing.T) { + r := NewRegistry() + c1 := r.Counter("same_name") + c2 := r.Counter("same_name") + + c1.Inc() + if v := c2.Value(); v != 1 { + t.Fatalf("expected shared counter to be 1, got %d", v) + } +} + +func TestGaugeSet(t *testing.T) { + r := NewRegistry() + g := r.Gauge("test.gauge") + + g.Set(42) + if v := g.Value(); v != 42 { + t.Fatalf("expected 42 after Set, got %d", v) + } + g.Set(100) + if v := g.Value(); v != 100 { + t.Fatalf("expected 100 after Set, got %d", v) + } +} + +func TestGaugeIncDec(t *testing.T) { + r := NewRegistry() + g := r.Gauge("test.gauge_incdec") + + g.Inc() + g.Inc() + if v := g.Value(); v != 2 { + t.Fatalf("expected 2 after two Inc, got %d", v) + } + g.Dec() + if v := g.Value(); v != 1 { + t.Fatalf("expected 1 after Dec, got %d", v) + } +} + +func TestLatencyTrackerObserve(t *testing.T) { + r := NewRegistry() + lt := r.Latency("test.latency") + + lt.Observe(100 * time.Millisecond) + lt.Observe(200 * time.Millisecond) + lt.Observe(300 * time.Millisecond) + + s := lt.Summary() + if s.Count != 3 { + t.Fatalf("expected 3 samples, got %d", s.Count) + } + if s.Min != 100*time.Millisecond { + t.Fatalf("expected min 100ms, got %v", s.Min) + } + if s.Max != 300*time.Millisecond { + t.Fatalf("expected max 300ms, got %v", s.Max) + } + if s.Avg != 200*time.Millisecond { + t.Fatalf("expected avg 200ms, got %v", s.Avg) + } +} + +func TestLatencyTrackerSince(t *testing.T) { + r := NewRegistry() + lt := r.Latency("test.since") + + start := time.Now().Add(-50 * time.Millisecond) + lt.Since(start) + + s := lt.Summary() + if s.Count != 1 { + t.Fatalf("expected 1 sample, got %d", s.Count) + } + if s.Min < 40*time.Millisecond { + t.Fatalf("expected min >= 40ms, got %v", s.Min) + } +} + +func TestLatencyTrackerPercentiles(t *testing.T) { + r := NewRegistry() + lt := r.Latency("test.percentiles") + + // Add 100 samples: 1ms, 2ms, ..., 100ms + for i := 1; i <= 100; i++ { + lt.Observe(time.Duration(i) * time.Millisecond) + } + + s := lt.Summary() + if s.Count != 100 { + t.Fatalf("expected 100 samples, got %d", s.Count) + } + if s.P50 < 45*time.Millisecond || s.P50 > 55*time.Millisecond { + t.Fatalf("expected P50 around 50ms, got %v", s.P50) + } + if s.P95 < 90*time.Millisecond { + t.Fatalf("expected P95 >= 90ms, got %v", s.P95) + } + if s.P99 < 95*time.Millisecond { + t.Fatalf("expected P99 >= 95ms, got %v", s.P99) + } +} + +func TestLatencyTrackerEmptySummary(t *testing.T) { + r := NewRegistry() + lt := r.Latency("test.empty") + + s := lt.Summary() + if s.Count != 0 { + t.Fatalf("expected 0 samples, got %d", s.Count) + } + if s.Min != 0 || s.Max != 0 || s.Avg != 0 { + t.Fatalf("expected zero durations for empty tracker, got min=%v max=%v avg=%v", s.Min, s.Max, s.Avg) + } +} + +func TestRegistrySnapshot(t *testing.T) { + r := NewRegistry() + c := r.Counter("snap.counter") + g := r.Gauge("snap.gauge") + lt := r.Latency("snap.latency") + + c.Inc() + c.Inc() + g.Set(42) + lt.Observe(10 * time.Millisecond) + + snap := r.Snapshot() + if snap.Counters["snap.counter"] != 2 { + t.Fatalf("expected counter=2, got %d", snap.Counters["snap.counter"]) + } + if snap.Gauges["snap.gauge"] != 42 { + t.Fatalf("expected gauge=42, got %d", snap.Gauges["snap.gauge"]) + } + latSummary, ok := snap.Latency["snap.latency"] + if !ok { + t.Fatal("expected latency summary in snapshot") + } + if latSummary.Count != 1 { + t.Fatalf("expected 1 latency sample, got %d", latSummary.Count) + } +} + +func TestDefaultRegistry(t *testing.T) { + d := Default() + if d == nil { + t.Fatal("expected non-nil default registry") + } + // Same instance + if Default() != d { + t.Fatal("expected same default registry instance") + } +} + +func TestLogEvent(t *testing.T) { + // Just verify it doesn't panic + LogEvent("test.event", "sess-1", "req-1", map[string]interface{}{ + "key": "value", + }) + LogEvent("test.event", "", "", nil) +} + +func TestPredefinedMetricNames(t *testing.T) { + // Verify predefined names are non-empty and follow naming convention + names := []string{ + SessionCreated, SessionDeleted, SessionActive, SessionPromptLatency, + RequestTotal, RequestLatency, RequestErrors, + ToolCallCount, ToolCallDuration, ToolCallErrors, + PermissionDenials, + AgentExecDuration, AgentExecFailures, + OutputSize, + JobSubmitted, JobCompleted, JobFailed, JobDuration, + WSConnections, WSSubscribers, WSDroppedSubs, + } + for _, name := range names { + if name == "" { + t.Fatal("empty metric name") + } + } +} From 732d945c59734521a6207c4fac6f23d58842111b Mon Sep 17 00:00:00 2001 From: chaoyuepan Date: Sat, 18 Apr 2026 17:01:56 +0800 Subject: [PATCH 2/2] feat: implement issue #11 - feat: add observability for sessions, tools, and agent execution Implemented by autoresearch with score 86/100 after 7 iterations. Closes #11 --- .autoresearch/results.tsv | 3 + .autoresearch/workflows/issue-11/.last_score | 1 + .../workflows/issue-11/iteration-1-claude.log | 35 + .../issue-11/iteration-2-claude-review.log | 204 + .../workflows/issue-11/iteration-2-claude.log | 20 + .../issue-11/iteration-3-codex-review.log | 5656 +++++++++++++++++ .../issue-11/iteration-4-opencode-review.log | 1 + .../issue-11/iteration-5-codex-review.log | 5414 ++++++++++++++++ .../issue-11/iteration-6-opencode-review.log | 1 + .../issue-11/iteration-7-claude-review.log | 206 + .autoresearch/workflows/issue-11/log.md | 47 + .autoresearch/workflows/issue-11/test-1.log | 765 +++ .autoresearch/workflows/issue-11/test-2.log | 3977 ++++++++++++ internal/agent/agent.go | 12 +- internal/gateway/server.go | 13 +- internal/metrics/metrics.go | 65 +- internal/metrics/metrics_test.go | 106 + 17 files changed, 16490 insertions(+), 36 deletions(-) create mode 100644 .autoresearch/workflows/issue-11/.last_score create mode 100644 .autoresearch/workflows/issue-11/iteration-1-claude.log create mode 100644 .autoresearch/workflows/issue-11/iteration-2-claude-review.log create mode 100644 .autoresearch/workflows/issue-11/iteration-2-claude.log create mode 100644 .autoresearch/workflows/issue-11/iteration-3-codex-review.log create mode 100644 .autoresearch/workflows/issue-11/iteration-4-opencode-review.log create mode 100644 .autoresearch/workflows/issue-11/iteration-5-codex-review.log create mode 100644 .autoresearch/workflows/issue-11/iteration-6-opencode-review.log create mode 100644 .autoresearch/workflows/issue-11/iteration-7-claude-review.log create mode 100644 .autoresearch/workflows/issue-11/test-1.log create mode 100644 .autoresearch/workflows/issue-11/test-2.log diff --git a/.autoresearch/results.tsv b/.autoresearch/results.tsv index 737f1d9..2ad710e 100644 --- a/.autoresearch/results.tsv +++ b/.autoresearch/results.tsv @@ -1 +1,4 @@ 2026-04-18T12:14:54+08:00 9 feat: support multiple subscribers for the same live session completed 2 true 88 88 feature/issue-9 +2026-04-18T16:16:12+08:00 11 feat: add observability for sessions, tools, and agent execution agent_failed 4 true 82 82 feature/issue-11 +2026-04-18T16:39:53+08:00 11 feat: add observability for sessions, tools, and agent execution agent_failed 6 true 0 0 feature/issue-11 +2026-04-18T17:01:56+08:00 11 feat: add observability for sessions, tools, and agent execution completed 7 true 86 86 feature/issue-11 diff --git a/.autoresearch/workflows/issue-11/.last_score b/.autoresearch/workflows/issue-11/.last_score new file mode 100644 index 0000000..8cf5c1a --- /dev/null +++ b/.autoresearch/workflows/issue-11/.last_score @@ -0,0 +1 @@ +86 diff --git a/.autoresearch/workflows/issue-11/iteration-1-claude.log b/.autoresearch/workflows/issue-11/iteration-1-claude.log new file mode 100644 index 0000000..2589850 --- /dev/null +++ b/.autoresearch/workflows/issue-11/iteration-1-claude.log @@ -0,0 +1,35 @@ +实现已完成。以下是工作总结: + +## 实现总结 + +### 新增文件 +- **`internal/metrics/metrics.go`** — 核心可观测性包,包含: + - `Counter` — 原子计数器(会话、请求、工具、权限等) + - `Gauge` — 当前值追踪(活跃连接、订阅者) + - `LatencyTracker` — 带有 p50/p95/p99 摘要的延迟直方图 + - `Registry` — 中央注册表,按名称查找并快照导出 + - `LogEvent()` — 用于关键操作事件的结构化事件日志记录器 + - 20 个预定义的仪表板友好型指标名称(`session.prompt_latency`, `tool.call_count` 等) + +- **`internal/metrics/metrics_test.go`** — 96.7% 覆盖率的测试 + +### 修改文件 +- **`internal/gateway/server.go`** — 添加了指标点: + - RPC 请求计数/延迟/错误 + - 会话生命周期(创建、活跃计数) + - 工具调用追踪(计数、持续时间、按工具名称划分的错误) + - 输出大小追踪 + - 作业提交/完成/失败 + - WebSocket 连接计数 + +- **`internal/agent/agent.go`** — 添加了: + - 代理执行延迟追踪 + - 权限拒绝检测和计数 + - 流持续时间追踪 + +- **`internal/gateway/stream_hub.go`** — 添加了订阅者指标和丢弃计数 + +### 验证 +- 所有测试通过(`go test ./...`) +- `go vet` 没有错误 +- 指标测试覆盖率:96.7% diff --git a/.autoresearch/workflows/issue-11/iteration-2-claude-review.log b/.autoresearch/workflows/issue-11/iteration-2-claude-review.log new file mode 100644 index 0000000..bdf4a3f --- /dev/null +++ b/.autoresearch/workflows/issue-11/iteration-2-claude-review.log @@ -0,0 +1,204 @@ +Now I have all the information needed for a comprehensive review. Here's my assessment: + +--- + +## 审核报告 + +### Issue +- 编号: #11 +- 标题: feat: add observability for sessions, tools, and agent execution +- 迭代次数: 1 + +--- + +### 总体评价 + +**评分: 82/100** + +实现结构清晰,metrics 包设计简洁实用,正确覆盖了 Issue 要求的所有可观测性维度。主要问题集中在 LatencyTracker 的内存无上限增长和并发安全问题。 + +--- + +### 严重问题 (必须修复) + +#### 问题 1: LatencyTracker 无界内存增长 + +**位置**: `internal/metrics/metrics.go:47-48` + +**问题描述**: +`LatencyTracker.samples` 是一个无限增长的 slice,每次 `Observe` 都 append 一个元素,永远不会被清理。在生产环境中长期运行的服务器会持续积累 latency 样本,导致内存泄漏。比如一个高频调用的 API 每秒 1000 次请求,一天就会积累 8640 万条 `time.Duration`(每条 8 字节),约 700MB。 + +**代码片段**: +```go +type LatencyTracker struct { + name string + mu sync.Mutex + samples []time.Duration // 无界增长 +} + +func (lt *LatencyTracker) Observe(d time.Duration) { + lt.mu.Lock() + lt.samples = append(lt.samples, d) // 只增不删 + lt.mu.Unlock() +} +``` + +**建议修复**: +使用固定大小的环形缓冲区(ring buffer)或滑动窗口,只保留最近 N 个样本。例如: + +```go +const maxSamples = 1000 + +type LatencyTracker struct { + name string + mu sync.Mutex + samples []time.Duration + head int // 环形写入位置 + full bool // 是否已写满一圈 +} + +func (lt *LatencyTracker) Observe(d time.Duration) { + lt.mu.Lock() + if len(lt.samples) < maxSamples { + lt.samples = append(lt.samples, d) + } else { + lt.samples[lt.head] = d + lt.head = (lt.head + 1) % maxSamples + lt.full = true + } + lt.mu.Unlock() + log.Printf("[metrics] latency %s duration_ms=%.2f", lt.name, float64(d)/float64(time.Millisecond)) +} +``` + +**原因**: 长期运行的服务会因 metrics 累积而 OOM,这是一个生产环境致命问题。 + +--- + +#### 问题 2: LatencyTracker.Summary 持锁时间过长 + +**位置**: `internal/metrics/metrics.go:67-74` + +**问题描述**: +`Summary()` 在持有互斥锁的同时拷贝所有样本,然后释放锁。虽然拷贝比在锁内计算要好,但对于大样本集(与问题 1 相关),拷贝本身就会阻塞所有 `Observe` 调用较长时间。结合问题 1 的修复(环形缓冲区),拷贝量有上限,此问题会自然缓解。 + +**原因**: 高频采样场景下,Snapshot 调用会阻塞所有延迟记录。 + +--- + +### 一般问题 (建议修复) + +#### 问题 3: SessionActive gauge 使用 Set 而非 Inc/Dec,语义不准确 + +**位置**: `internal/gateway/server.go:1103` + +**问题描述**: +`session.active_count` 在 `recordPrompt` 中用 `Set(int64(len(s.sessionMgr.Summaries())))` 设置值。但 `Summaries()` 返回所有会话的摘要,不只是活跃会话。另外,session 删除时(`handleSessionDelete`)没有更新这个 gauge,导致值不一致。 + +**代码片段**: +```go +metrics.Default().Gauge(metrics.SessionActive).Set(int64(len(s.sessionMgr.Summaries()))) +``` + +**建议修复**: +1. 在 session 删除时也更新 gauge(使用 `SessionDeleted` counter 对应的位置) +2. 如果 `Summaries()` 返回的是全部 session 而非活跃 session,metric 名称应考虑改为 `session.total_count` + +**原因**: gauge 值在删除 session 后会过时,与实际状态不一致。 + +--- + +#### 问题 4: session.deleted counter 已定义但未使用 + +**位置**: `internal/metrics/metrics.go:220` + +**问题描述**: +`SessionDeleted` 常量已定义,但 `handleSessionDelete` 中从未调用 `metrics.Default().Counter(metrics.SessionDeleted).Inc()`。 + +**建议修复**: +在 `handleSessionDelete` 中添加: +```go +metrics.Default().Counter(metrics.SessionDeleted).Inc() +``` + +**原因**: 定义了但不使用的 metric 会误导使用者认为该指标可观测。 + +--- + +#### 问题 5: truncate 函数在两个包中重复定义 + +**位置**: `internal/agent/agent.go:1058-1063`, `internal/gateway/server.go:1348-1353` + +**问题描述**: +完全相同的 `truncate` 函数在 `agent` 和 `gateway` 两个包中各定义了一份。 + +**建议修复**: +将 `truncate` 移到 `internal/metrics` 包中并导出,或放在一个共享的 util 包中。或保持现状但添加注释说明两处使用。 + +**原因**: 代码重复,维护时容易遗漏同步更新。 + +--- + +#### 问题 6: toolStartTimes map 无清理机制 + +**位置**: `internal/gateway/server.go:963` + +**问题描述**: +`toolStartTimes := make(map[string]time.Time)` 在 `handleAskStream` 中创建,用于追踪 tool 调用时长。但如果 stream 提前中断(连接断开等),某些 tool 的 start time 会被记录但对应的 end 事件不会到来,导致 `toolStartTimes` 中的条目泄漏(不过因为是局部变量,函数返回后会 GC,所以影响有限)。 + +**建议**: 这是轻微问题,局部变量作用域有限。但如果未来重构为持久化追踪,需注意清理。 + +--- + +#### 问题 7: LatencySummary 包含完整 Samples slice + +**位置**: `internal/metrics/metrics.go:87` + +**问题描述**: +`LatencySummary.Samples` 字段暴露了所有原始样本。通过 `Snapshot()` 返回时,这会导致大量内存拷贝(同样与问题 1 相关)。 + +**建议修复**: +考虑移除 `Samples` 字段,或改为可选。大多数消费者只需要聚合统计(count, p50, p95, p99),不需要原始数据。 + +**原因**: 暴露所有样本有内存开销,且 `Snapshot()` 会拷贝全部数据。 + +--- + +### 建议改进 (可选) + +- **Counter.Add 负数问题**: `Counter.Add(n)` 接受 `int64`,传入负数会让 counter 减小,但 counter 语义上应该只增不减。可以添加 `n < 0` 的保护或文档说明。 +- **LogEvent extra 参数 map 顺序不确定**: Go map 迭代顺序随机,导致 `LogEvent` 输出中 extra 字段的顺序不一致,不利于日志解析。可考虑排序 key 后输出。 +- **并发测试**: metrics 包的测试没有覆盖并发场景(多个 goroutine 同时 Inc/Observe),可以添加 `go test -race` 验证。 +- **Percentile 计算精度**: 当前使用 `sorted[n*50/100]` 的方式,对于小样本集(如 n=3)可能不够精确,但对于此项目的可观测性需求足够。 + +--- + +### 审核维度评分 + +| 维度 | 得分 | 说明 | +|------|------|------| +| 正确性 | 75 | LatencyTracker 无界增长是生产级问题;SessionActive gauge 不一致;SessionDeleted 未使用 | +| 测试质量 | 90 | 96.7% 覆盖率,核心逻辑都有测试,缺少并发测试 | +| 代码质量 | 85 | 代码结构清晰,命名规范,有重复的 truncate 函数 | +| 安全性 | 95 | 无安全风险,仅限内部可观测性 | +| 性能 | 60 | LatencyTracker 无界增长 + 持锁拷贝,长期运行有内存和延迟风险 | + +### 综合评分 + +``` +总分 = 75 × 0.35 + 90 × 0.25 + 85 × 0.20 + 95 × 0.10 + 60 × 0.10 + = 26.25 + 22.5 + 17.0 + 9.5 + 6.0 + = 81.25 ≈ 82 +``` + +--- + +### 审核结论 + +- [x] 需要修改 - 存在必须修复的问题 + +**下一步行动**: +1. **必须**: 修复 LatencyTracker 无界内存增长(使用环形缓冲区或限制样本数) +2. **必须**: 在 `handleSessionDelete` 中使用 `SessionDeleted` counter +3. **建议**: 修正 `SessionActive` gauge 的更新逻辑,确保删除 session 时也更新 +4. **建议**: 去重 `truncate` 函数 diff --git a/.autoresearch/workflows/issue-11/iteration-2-claude.log b/.autoresearch/workflows/issue-11/iteration-2-claude.log new file mode 100644 index 0000000..26708bc --- /dev/null +++ b/.autoresearch/workflows/issue-11/iteration-2-claude.log @@ -0,0 +1,20 @@ +所有修复已完成。以下是变更摘要: + +### 所做更改 + +| # | 问题 | 修复 | 文件 | +|---|-------|-----|------| +| 1 | **LatencyTracker 无限内存增长** | 将 `[]time.Duration` slice 替换为固定大小(1000)的环形缓冲区 | `internal/metrics/metrics.go` | +| 2 | **Summary 持锁时间过长** | 环形缓冲区限制拷贝大小,自然解决问题 | 同上 | +| 3 | **SessionActive gauge 在删除时不更新** | 在 `handleSessionDelete` 中添加了 `SessionActive` gauge 更新 | `internal/gateway/server.go` | +| 4 | **SessionDeleted counter 已定义但未使用** | 在 `handleSessionDelete` 中添加了 `Counter(SessionDeleted).Inc()` | `internal/gateway/server.go` | +| 5 | **truncate 函数重复** | 移至 `metrics.Truncate`(导出),并从 `agent.go` 和 `server.go` 中移除了本地副本 | 3个文件 | +| 6 | **LatencySummary.Samples 暴露所有原始数据** | 从结构体中移除了 `Samples` 字段 | `internal/metrics/metrics.go` | +| 7 | **Counter.Add 接受负值** | 为负值添加了守卫(记录日志并忽略) | `internal/metrics/metrics.go` | + +### 新增测试 +- `TestCounterAddNegative` - 验证负值被忽略 +- `TestLatencyTrackerRingBuffer` - 验证环形缓冲区在 maxSamples 之后进行封顶 +- `TestLatencyTrackerRingBufferExactlyFull` - 边界情况:恰好 maxSamples +- `TestLatencyTrackerConcurrentObserve` - 使用 race detector 的并发测试 +- `TestTruncate` - 验证共享的 Truncate 函数 diff --git a/.autoresearch/workflows/issue-11/iteration-3-codex-review.log b/.autoresearch/workflows/issue-11/iteration-3-codex-review.log new file mode 100644 index 0000000..0665b5d --- /dev/null +++ b/.autoresearch/workflows/issue-11/iteration-3-codex-review.log @@ -0,0 +1,5656 @@ +OpenAI Codex v0.121.0 (research preview) +-------- +workdir: /Users/chaoyuepan/ai/imclaw +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: workspace-write [workdir, /tmp, $TMPDIR, /Users/chaoyuepan/.codex/memories] +reasoning effort: medium +reasoning summaries: none +session id: 019d9fa5-0e06-7de1-adec-f3eafc5f15fd +-------- +user +审核 Issue #11 的实现 + +项目路径: /Users/chaoyuepan/ai/imclaw +项目语言: go +Issue 标题: feat: add observability for sessions, tools, and agent execution + +--- +请审核代码并给出评分和改进建议: +# Codex Agent + +你是一个专业的软件工程师 Agent,既能实现功能,也能审核代码。 + +--- + +## 角色定位 + +**你可以是实现者,也可以是审核者,取决于任务要求。** + +- 作为实现者:根据 Issue 描述或审核反馈实现/改进代码 +- 作为审核者:审查代码质量,给出评分和改进建议 +- 你需要编写代码和测试 +- 你需要接受审核反馈并改进 + +--- + +## 工作流程 + +### Phase 1: 理解需求 + +``` +1. 阅读 Issue #N 的完整内容 +2. 理解 Issue 的核心诉求 +3. 如果有疑问,列出需要澄清的问题 +4. 确认涉及的代码模块 +``` + +### Phase 2: 分析代码 + +``` +1. 使用 Glob 和 Grep 工具搜索相关代码 +2. 阅读相关文件,理解现有架构 +3. 识别需要修改的文件 +4. 评估改动范围和影响 +``` + +### Phase 3: 实现代码 + +``` +1. 编写功能实现代码 +2. 编写单元测试代码 +3. 确保测试覆盖核心逻辑 +4. 运行测试验证实现 +``` + +--- + +## 输出格式 + +每次实现完成后,你必须输出以下结构: + +```markdown +## 实现报告 + +### Issue +- 编号: #N +- 标题: [Issue 标题] +- 类型: feature / bugfix / refactor / docs + +### 改动概述 +- 修改文件: [文件列表] +- 新增文件: [文件列表] +- 删除文件: [文件列表] +- 代码行数: +X / -Y + +### 实现思路 +[描述你的实现思路,关键设计决策] + +### 测试情况 +- 测试文件: [测试文件路径] +- 测试用例数: N +- 覆盖场景: + - [场景1] + - [场景2] + - ... + +### 待确认问题 +- [列出需要审核者关注的问题,如果没有则写"无"] +``` + +--- + +## 代码规范 + +### Go 代码规范 + +```go +// 1. 包注释 +// Package parser 提供事件流解析功能。 +package parser + +// 2. 函数注释 +// Parse 解析输入数据并返回事件列表。 +// 如果输入格式无效,返回 ErrInvalidInput 错误。 +func Parse(input []byte) ([]Event, error) { ... } + +// 3. 错误处理 +if err != nil { + return fmt.Errorf("parse failed: %w", err) +} + +// 4. 表格驱动测试 +func TestParse(t *testing.T) { + tests := []struct { + name string + input string + want []Event + wantErr bool + }{ + {"valid input", `{"type":"start"}`, []Event{{Type: "start"}}, false}, + {"invalid json", `{invalid}`, nil, true}, + {"empty input", ``, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse([]byte(tt.input)) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() = %v, want %v", got, tt.want) + } + }) + } +} +``` + +### 禁止事项 + +``` +❌ 不要使用 panic(除非在不可恢复的错误场景) +❌ 不要忽略错误返回值 +❌ 不要使用全局变量 +❌ 不要在测试中使用 time.Sleep +❌ 不要硬编码配置值 +❌ 不要提交注释掉的代码 +❌ 不要使用魔法数字 +``` + +--- + +## 面对审核反馈 + +当审核者 (Claude) 提出批评意见时: + +### 1. 理解反馈 + +```markdown +## 反馈理解 + +审核者提出以下问题: +1. [问题1] - 我的理解:[描述理解] +2. [问题2] - 我的理解:[描述理解] + +我是否正确理解了反馈? +``` + +### 2. 分类处理 + +``` +| 反馈类型 | 处理方式 | +|---------|---------| +| 严重 Bug | 立即修复 | +| 设计问题 | 评估影响后修复 | +| 代码风格 | 按规范修改 | +| 性能问题 | 评估必要性后处理 | +| 建议性意见 | 根据实际情况决定是否采纳 | +``` + +### 3. 改进报告 + +```markdown +## 改进报告 + +### 已修复问题 +- [问题1]: [修复方案] +- [问题2]: [修复方案] + +### 未采纳建议 +- [建议]: [原因] + +### 新增改动 +- [描述新增的改动] +``` + +--- + +## 特殊情况处理 + +### 无法实现 + +如果 Issue 描述不清或技术上不可行: + +```markdown +## 无法实现报告 + +### Issue +- 编号: #N + +### 阻塞原因 +- [ ] 需求不明确 +- [ ] 缺少必要上下文 +- [ ] 技术限制 +- [ ] 依赖未解决 + +### 详细说明 +[描述具体原因] + +### 建议操作 +- [建议人类如何处理这个 Issue] +``` + +### 需要设计决策 + +如果 Issue 涉及架构级别的决策: + +```markdown +## 需要设计评审 + +### Issue +- 编号: #N + +### 设计问题 +[描述需要决策的问题] + +### 可选方案 +1. 方案A: [描述] - 优点: [] 缺点: [] +2. 方案B: [描述] - 优点: [] 缺点: [] + +### 建议 +[你的建议和理由] + +### 请人工确认后继续 +``` + +--- + +## 迭代限制 + +- 最大迭代次数: 5 次 +- 如果连续 3 次测试失败,报告阻塞 +- 如果审核者满意度不再提升,可以停止迭代并说明原因 + +--- + +## 示例对话 + +### 示例 1: 功能实现 + +**Human**: 实现 Issue #42 - 添加用户认证功能 + +**Codex**: +``` +我来分析 Issue #42 并实现用户认证功能。 + +首先,让我了解现有代码结构... +[使用 Glob/Grep 搜索相关代码] + +分析完成,我计划: +1. 在 internal/auth/ 目录下创建认证模块 +2. 实现 JWT token 生成和验证 +3. 添加认证中间件 +4. 编写单元测试 + +开始实现... +[编写代码] + +运行测试... +go test ./internal/auth/... -v + +## 实现报告 +[按格式输出报告] +``` + +### 示例 2: 处理审核反馈 + +**Claude**: "代码中使用了硬编码的密钥,存在安全风险..." + +**Codex**: +``` +## 反馈理解 + +审核者指出: +- 问题: 硬编码密钥存在安全风险 + +我的理解: 应该从配置或环境变量读取密钥 + +## 改进 + +修改方案: +1. 将密钥改为从环境变量读取 +2. 添加默认值用于开发环境 +3. 添加配置验证 + +[修改代码] + +## 改进报告 +[按格式输出报告] +``` + +codex +我会按代码审核流程先定位 `Issue #11` 的实现范围,然后阅读关键改动和测试,最后给出按严重度排序的问题清单、评分和改进建议。先从 `git` 历史与关键词检索开始,确认这次实现具体改了哪些文件。 +exec +/bin/zsh -lc 'git -C /Users/chaoyuepan/ai/imclaw status --short' in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: + M .autoresearch/workflows/issue-11/log.md + M internal/agent/agent.go + M internal/gateway/server.go + M internal/metrics/metrics.go + M internal/metrics/metrics_test.go +?? .autoresearch/workflows/issue-11/.last_score +?? .autoresearch/workflows/issue-11/iteration-1-claude.log +?? .autoresearch/workflows/issue-11/iteration-2-claude-review.log +?? .autoresearch/workflows/issue-11/iteration-2-claude.log +?? .autoresearch/workflows/issue-11/iteration-3-codex-review.log +?? .autoresearch/workflows/issue-11/test-1.log +?? .autoresearch/workflows/issue-11/test-2.log + +exec +/bin/zsh -lc 'git -C /Users/chaoyuepan/ai/imclaw log --oneline --decorate -n 20' in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: +08f3bbc (HEAD -> feature/issue-11) feat: add observability for sessions, tools, and agent execution (#11) +30c8769 (origin/master, origin/HEAD, master) refactor: move autoresearch to standalone repo +48a0a70 feat(autoresearch): include final review report in issue closing comment +8ea7d74 Merge pull request #30 from smallnest/feature/issue-9 +6cc4a2f (origin/feature/issue-9) feat: implement issue #9 - feat: support multiple subscribers for the same live session +bdf4675 fix(autoresearch): remove local keyword outside function scope +8cd9b30 feat(autoresearch): add issue comment and close after PR merge +2ba8986 Merge pull request #29 from smallnest/feature/session-lifecycle +621e882 (origin/feature/session-lifecycle) feat: add session lifecycle management and cleanup autoresearch artifacts +c77b2f0 feat(autoresearch): add OpenCode as third agent +c8b446a Merge pull request #28 from smallnest/feature/issue-8 +fd2a65f (origin/feature/issue-8) refactor(autoresearch): improve iteration flow and scoring +bf87b3c Merge pull request #27 from smallnest/feature/issue-8 +de18bbc refactor(autoresearch): direct agent calls and alternating review flow +c509ca1 Merge pull request #26 from smallnest/feature/issue-21 +5ddee00 (origin/feature/issue-21, feature/issue-21) feat: implement issue #21 - feat: enhance job execution with agent selection and timeout +c54bbc0 feat: implement issue #21 - add timeout support and enhance agent selection for job execution +fe914db Merge pull request #25 from smallnest/feature/issue-20 +69641ad (origin/feature/issue-20) feat: implement issue #20 - test: add unit tests for Job API +38ea86d feat(autoresearch): auto-create and merge PR on completion + +exec +/bin/zsh -lc "rg -n \"observability|session|tool|agent execution|otel|trace|metrics|logger|instrument\" /Users/chaoyuepan/ai/imclaw --glob '*.go'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics_test.go:1:package metrics +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:89: tools, parseErr := parseTools(policy.AllowedTools) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:93: resolved.AllowedTools = tools +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:97: tools, parseErr := parseTools(policy.DeniedTools) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:101: resolved.DeniedTools = tools +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:102: resolved.AllowedTools = subtractTools(resolved.AllowedTools, tools) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:169: tool := strings.TrimSpace(part) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:170: if tool == "" { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:173: if !isKnownTool(tool) { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:174: return nil, fmt.Errorf("unknown tool %q in permission policy (valid tools: %s)", tool, strings.Join(KnownTools(), ", ")) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:176: set[tool] = struct{}{} +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:179: tools := make([]string, 0, len(set)) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:180: for _, tool := range knownTools { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:181: if _, ok := set[tool]; ok { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:182: tools = append(tools, tool) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:185: return tools, nil +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:188:func isKnownTool(tool string) bool { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:190: if tool == known { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:202: for _, tool := range denied { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:203: denySet[tool] = struct{}{} +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:206: for _, tool := range allowed { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:207: if _, denied := denySet[tool]; denied { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:210: filtered = append(filtered, tool) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:215:func SortedTools(tools []string) []string { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy.go:216: out := append([]string(nil), tools...) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:26: t.Fatalf("Denied tools still present: %#v", resolved.AllowedTools) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:64: t.Fatal("expected error for unknown tool") +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:66: // Verify error message includes list of valid tools +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:68: if !strings.Contains(errMsg, "valid tools:") { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:69: t.Errorf("Error message should list valid tools, got: %s", errMsg) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:71: // Check that error includes some known tools +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:73: t.Errorf("Error message should include known tool names, got: %s", errMsg) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:84: name: "empty tools", +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:89: name: "single tool", +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:94: name: "multiple tools", +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:130: name: "policy with allowed tools", +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:138: name: "policy with denied tools", +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:223: t.Errorf("Default allowed tools = %#v, want %#v", resolved.AllowedTools, expectedTools) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:237: for _, tool := range resolved.AllowedTools { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:238: if tool == "Read" { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:270: t.Errorf("After denying all tools, AllowedTools = %#v, want empty slice", resolved.AllowedTools) +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:274:func contains(tools []string, target string) bool { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:275: for _, tool := range tools { +/Users/chaoyuepan/ai/imclaw/internal/permission/policy_test.go:276: if tool == target { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent_test.go:41: if strings.Contains(joined, "--allowed-tools Bash,Edit,Glob,Grep,LS,MultiEdit,NotebookEdit,Read,TodoWrite,WebFetch,WebSearch,Write") || strings.Contains(joined, ",Write,") || strings.Contains(joined, ",Write ") || strings.Contains(joined, " Write,") { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent_test.go:42: t.Fatalf("denied tool leaked into args: %q", joined) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent_test.go:119: events := parser.Feed("[thinking] plan\n[tool] Read (pending)\n path=/tmp\n[tool] Read (completed)\n ok\nanswer\n") +/Users/chaoyuepan/ai/imclaw/internal/agent/agent_test.go:146: t.Fatalf("unexpected tool_end payload: %#v", events[6]) +/Users/chaoyuepan/ai/imclaw/internal/session/export_test.go:1:package session +/Users/chaoyuepan/ai/imclaw/internal/session/export_test.go:42: t.Fatal("expected session in export") +/Users/chaoyuepan/ai/imclaw/internal/session/export_test.go:45: t.Fatalf("expected session ID 'test-1', got %q", exportData.Session.ID) +/Users/chaoyuepan/ai/imclaw/internal/session/export_test.go:84: t.Fatalf("expected session name in markdown header, got:\n%s", md) +/Users/chaoyuepan/ai/imclaw/internal/session/export_test.go:129: t.Fatal("expected error for nil session") +/Users/chaoyuepan/ai/imclaw/internal/session/export_test.go:134: // First export a session +/Users/chaoyuepan/ai/imclaw/internal/session/export_test.go:185: t.Fatal("expected error for missing session field") +/Users/chaoyuepan/ai/imclaw/internal/session/export_test.go:249: Name: "tool_use", +/Users/chaoyuepan/ai/imclaw/internal/session/export_test.go:270: if !strings.Contains(md, "**Name**: tool_use") { +/Users/chaoyuepan/ai/imclaw/internal/session/export_test.go:289: validData := []byte(`{"exported_at":"2025-01-01T00:00:00Z","format":"json","version":"1.0","session":{"id":"v1","metadata":{}}}`) +/Users/chaoyuepan/ai/imclaw/internal/session/export_test.go:296: invalidData := []byte(`{"exported_at":"2025-01-01T00:00:00Z","format":"json","version":"2.0","session":{"id":"v2","metadata":{}}}`) +/Users/chaoyuepan/ai/imclaw/internal/session/export_test.go:306: emptyVerData := []byte(`{"exported_at":"2025-01-01T00:00:00Z","format":"json","version":"","session":{"id":"v3","metadata":{}}}`) +/Users/chaoyuepan/ai/imclaw/internal/session/session_test.go:1:package session +/Users/chaoyuepan/ai/imclaw/internal/session/session_test.go:18: t.Fatalf("expected running session after prompt, got active=%v status=%q", updated.Active, updated.Status) +/Users/chaoyuepan/ai/imclaw/internal/session/session_test.go:34: t.Fatalf("unexpected session summary after result: %#v", updated) +/Users/chaoyuepan/ai/imclaw/internal/session/session_test.go:42: t.Fatalf("unexpected session error state: %#v", updated) +/Users/chaoyuepan/ai/imclaw/internal/session/session_test.go:51: t.Fatal("expected prompt to update session ordering") +/Users/chaoyuepan/ai/imclaw/internal/session/session_test.go:59: t.Fatalf("expected most recently active session first, got %#v", summaries) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:1:package session +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:11:// ActivityType identifies the type of a persisted session activity. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:15: // ActivityPrompt records a user prompt submitted to a session. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:25:// Activity captures a prompt, event, result, or error in a session timeline. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:37:// Session represents a conversation session. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:44: AgentSession string `json:"agent_session"` // ACPX internal session ID +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:45: AgentSessionHandle string `json:"agent_session_handle"` // session handle used for subsequent prompts +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:46: Name string `json:"name,omitempty"` // Human-readable session name +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:48: Archived bool `json:"archived"` // Archived sessions are hidden from default listings +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:82:// Manager manages sessions. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:85: sessions map[string]*Session // session key -> session +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:88:// NewManager creates a new session manager. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:91: sessions: make(map[string]*Session), +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:95:// SessionKey generates a session key. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:224:// Summary returns a lightweight session view for list rendering. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:247:// Create creates a new session. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:253: session := newSession(channel, accountID, chatID, agentName) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:254: m.sessions[key] = session +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:255: return cloneSession(session) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:258:// Get gets a session by key. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:263: session, ok := m.sessions[SessionKey(channel, chatID)] +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:264: return cloneSession(session), ok +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:267:// GetOrCreate gets or creates a session. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:273: if sess, ok := m.sessions[key]; ok { +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:278: session := newSession(channel, accountID, chatID, defaultAgent) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:279: m.sessions[key] = session +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:280: return cloneSession(session) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:283:// Delete deletes a session. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:288: delete(m.sessions, SessionKey(channel, chatID)) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:291:// Update updates a session. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:292:func (m *Manager) Update(session *Session) { +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:296: cloned := cloneSession(session) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:298: m.sessions[cloned.ID] = cloned +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:301:// List lists all sessions. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:306: sessions := make([]*Session, 0, len(m.sessions)) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:307: for _, sess := range m.sessions { +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:308: sessions = append(sessions, cloneSession(sess)) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:310: sort.Slice(sessions, func(i, j int) bool { +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:311: return sessions[i].LastActive.After(sessions[j].LastActive) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:313: return sessions +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:316:// Summaries lists all sessions using a lightweight projection. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:321: summaries := make([]SessionSummary, 0, len(m.sessions)) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:322: for _, sess := range m.sessions { +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:331:// SummariesFiltered returns session summaries with optional filtering. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:332:// When tag is non-empty, only sessions with that tag are returned. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:333:// When includeArchived is false, archived sessions are excluded. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:338: summaries := make([]SessionSummary, 0, len(m.sessions)) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:339: for _, sess := range m.sessions { +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:364:// RecordPrompt appends a prompt activity to the session timeline. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:369: sess, ok := m.sessions[SessionKey(channel, chatID)] +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:377:// RecordEvent appends an event activity to the session timeline. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:382: sess, ok := m.sessions[SessionKey(channel, chatID)] +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:390:// RecordResult appends a final result activity to the session timeline. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:395: sess, ok := m.sessions[SessionKey(channel, chatID)] +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:403:// RecordError appends an error activity to the session timeline. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:408: sess, ok := m.sessions[SessionKey(channel, chatID)] +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:416:// Cleanup cleans up expired sessions. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:423: for key, sess := range m.sessions { +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:425: delete(m.sessions, key) +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:432:// Rename sets a human-readable name for the session. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:437: sess, ok := m.sessions[SessionKey(channel, chatID)] +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:446:// AddTag adds a tag to the session. Returns the updated session or false if not found. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:451: sess, ok := m.sessions[SessionKey(channel, chatID)] +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:465:// RemoveTag removes a tag from the session. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:470: sess, ok := m.sessions[SessionKey(channel, chatID)] +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:484:// SetTags replaces all tags on the session. Duplicate tags are removed. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:489: sess, ok := m.sessions[SessionKey(channel, chatID)] +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:498:// Archive marks a session as archived. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:503: sess, ok := m.sessions[SessionKey(channel, chatID)] +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:512:// Unarchive removes the archived flag from a session. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:517: sess, ok := m.sessions[SessionKey(channel, chatID)] +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:526:// SessionUpdates contains the fields to update on a session in a single atomic operation. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:535:// ApplyUpdates atomically applies multiple updates to a session within a single lock. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:541: sess, ok := m.sessions[SessionKey(channel, chatID)] +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:599:// ListByTag returns sessions that have the specified tag. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:605: for _, sess := range m.sessions { +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:619:// ListArchived returns all archived sessions. +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:625: for _, sess := range m.sessions { +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:1:package session +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:23: t.Fatal("expected session to exist") +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:34: t.Fatal("expected rename to fail for nonexistent session") +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:105: t.Fatal("new session should not be archived") +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:114: t.Fatal("expected session to be archived") +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:129: t.Fatal("expected session to be unarchived") +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:137: t.Fatal("expected archive to fail for nonexistent session") +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:145: t.Fatal("expected unarchive to fail for nonexistent session") +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:161: t.Fatalf("expected 2 sessions with 'important' tag, got %d", len(tagged)) +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:166: t.Fatalf("expected 1 session with 'review' tag, got %d", len(tagged)) +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:171: t.Fatalf("expected 0 sessions, got %d", len(tagged)) +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:186: t.Fatalf("expected 2 archived sessions, got %d", len(archived)) +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:250: t.Fatal("expected SetTags to fail for nonexistent session") +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:308: // Verify the session is still valid (no panic, no corruption) +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:311: t.Fatal("expected session to exist after concurrent operations") +/Users/chaoyuepan/ai/imclaw/internal/session/session_lifecycle_test.go:314: t.Fatalf("session ID corrupted: %q", final.ID) +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:11:// It recognizes fine-grained tool lifecycle events like tool_start and tool_end. +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:17: toolName string +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:18: toolInput bytes.Buffer +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:19: toolOutput bytes.Buffer +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:21: collectOutput bool // Collecting tool output after (completed) +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:68: // Flush any pending tool +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:73: Input: strings.TrimSpace(p.toolInput.String()), +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:74: Output: strings.TrimSpace(p.toolOutput.String()), +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:77: p.toolInput.Reset() +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:78: p.toolOutput.Reset() +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:103: // If collecting tool output, check if this line continues it +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:106: p.toolOutput.WriteString(line) +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:107: p.toolOutput.WriteByte('\n') +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:110: // Non-indented line ends tool output +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:114: Input: strings.TrimSpace(p.toolInput.String()), +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:115: Output: strings.TrimSpace(p.toolOutput.String()), +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:118: p.toolInput.Reset() +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:119: p.toolOutput.Reset() +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:137: case "tool": +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:168: // Collect tool input lines (indented after tool_start) +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:170: p.toolInput.WriteString(line) +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:171: p.toolInput.WriteByte('\n') +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:174: // Non-indented line ends tool input, start output +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:195:// parseToolMarker parses a tool marker line and emits appropriate events. +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:200: var toolName, state string +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:202: toolName = strings.TrimSpace(strings.TrimSuffix(content, "(pending)")) +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:205: toolName = strings.TrimSpace(strings.TrimSuffix(content, "(completed)")) +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:208: toolName = strings.TrimSpace(strings.TrimSuffix(content, "(error)")) +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:217: p.toolName = toolName +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:218: p.toolInput.Reset() +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:219: p.toolOutput.Reset() +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:223: Name: toolName, +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:227: // Emit tool_end after collecting indented output lines +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:228: p.pendingTool = toolName +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:235: Name: toolName, +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:236: Input: strings.TrimSpace(p.toolInput.String()), +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:238: p.toolName = "" +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:239: p.toolInput.Reset() +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:240: p.toolOutput.Reset() +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:289: case "thinking", "tool", "done", "client", "acpx": +/Users/chaoyuepan/ai/imclaw/internal/session/export.go:1:package session +/Users/chaoyuepan/ai/imclaw/internal/session/export.go:10:// ExportFormat represents the format for session export. +/Users/chaoyuepan/ai/imclaw/internal/session/export.go:26: Session *Session `json:"session"` +/Users/chaoyuepan/ai/imclaw/internal/session/export.go:29:// ExportSession exports a session to the specified format, returning the serialized bytes. +/Users/chaoyuepan/ai/imclaw/internal/session/export.go:32: return nil, fmt.Errorf("session is nil") +/Users/chaoyuepan/ai/imclaw/internal/session/export.go:58: b.WriteString(fmt.Sprintf("# Session: %s\n\n", sessionDisplayName(sess))) +/Users/chaoyuepan/ai/imclaw/internal/session/export.go:114:// ImportSession imports a session from JSON export data. +/Users/chaoyuepan/ai/imclaw/internal/session/export.go:121: return nil, fmt.Errorf("export data contains no session") +/Users/chaoyuepan/ai/imclaw/internal/session/export.go:129:func sessionDisplayName(sess *Session) string { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw/imclaw.go:13: "github.com/smallnest/imclaw/internal/session" +/Users/chaoyuepan/ai/imclaw/cmd/imclaw/imclaw.go:58: // Create session manager +/Users/chaoyuepan/ai/imclaw/cmd/imclaw/imclaw.go:59: sessionMgr := session.NewManager() +/Users/chaoyuepan/ai/imclaw/cmd/imclaw/imclaw.go:76: srv := gateway.NewServer(cfg, sessionMgr, agentMgr, jobMgr) +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:11: // Tool events - granular tool lifecycle +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:12: TypeToolStart Type = "tool_start" // Tool execution started: "ToolName (pending)" +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:13: TypeToolInput Type = "tool_input" // Tool input parameters +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:14: TypeToolEnd Type = "tool_end" // Tool execution completed: "ToolName (completed)" +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:15: TypeToolError Type = "tool_error" // Tool execution failed +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:28: Name string `json:"name,omitempty"` // Tool name for tool events +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:29: Input string `json:"input,omitempty"` // Tool input for tool_input/tool_end +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:30: Output string `json:"output,omitempty"` // Tool output for tool_end +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:33:// IsTool returns true if the event is tool-related. +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:11: "github.com/smallnest/imclaw/internal/session" +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:20:func (s stubAgent) CreateSession(ctx context.Context, sessionName string) (string, error) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:23:func (s stubAgent) EnsureSession(ctx context.Context, sessionName string) (string, error) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:26:func (s stubAgent) Prompt(ctx context.Context, sessionID, prompt string) (string, error) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:29:func (s stubAgent) PromptWithOptions(ctx context.Context, sessionID, prompt string, opts *agent.PromptOptions) (string, error) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:32:func (s stubAgent) PromptStream(ctx context.Context, sessionID, prompt string, opts *agent.PromptOptions) (<-chan agent.StreamChunk, error) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:42: "allowed_tools": "Read,Grep", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:43: "denied_tools": "Grep", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:188: sessionMgr := session.NewManager() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:189: srv := NewServer(&Config{}, sessionMgr, agent.NewManager(), job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:190: sess := sessionMgr.Create(defaultSessionChannel, "", "sess-ensure", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:200: updated, ok := sessionMgr.Get(defaultSessionChannel, sess.ID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:202: t.Fatal("expected session to be updated") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:205: t.Fatalf("expected internal session id to be stored, got %#v", updated) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:208: t.Fatalf("expected session handle to remain stable, got %#v", updated) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:10: raw := `[tool] Read (pending) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:13:[tool] Read (completed) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:21: // Should emit: tool_start, tool_end, thinking +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:23: var toolStart, toolEnd, thinking bool +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:27: toolStart = true +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:29: t.Errorf("expected tool name 'Read', got %q", e.Name) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:32: toolEnd = true +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:34: t.Errorf("expected tool name 'Read', got %q", e.Name) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:41: if !toolStart { +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:42: t.Error("missing tool_start event") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:44: if !toolEnd { +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:45: t.Error("missing tool_end event") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:55: raw := `[tool] Write (pending) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:58:[tool] Write (error) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:64: // Should emit: tool_start, tool_error +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:71: t.Errorf("expected tool name 'Write', got %q", e.Name) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:76: t.Errorf("expected tool name 'Write', got %q", e.Name) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:82: t.Error("missing tool_start event") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:85: t.Error("missing tool_error event") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:96:[tool] Bash (pending) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:99:[tool] Bash (completed) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:107: // Should have: thinking, output, tool_start, tool_end, output +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:129: t.Error("missing tool_start event") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:132: t.Error("missing tool_end event") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:140: events := p.Feed("[tool] Read (pending)\n") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:142: t.Fatalf("expected tool_start after first chunk, got %#v", events) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:145: t.Fatalf("expected tool name 'Read', got %q", events[0].Name) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:148: // Feed tool input - indented lines are collected, no events emitted +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:151: t.Fatalf("expected no events for tool input, got %#v", events) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:155: events = p.Feed("[tool] Read (completed)\n output: test\n") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:158: // Feed a newline to trigger tool_end +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:163: // Find tool_end event +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:169: t.Errorf("expected tool name 'Read', got %q", e.Name) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:174: t.Errorf("expected tool_end event, got %#v", events) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:181: raw := `[acpx] session abc123 +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:1:// Package metrics provides lightweight observability for IMClaw via structured logs. +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:3:// All metrics are emitted through Go's standard log package with a consistent +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:4:// [metrics] prefix and structured key=value pairs, making them grep-friendly +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:6:package metrics +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:27: log.Printf("[metrics] counter %s=%d", c.name, v) +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:34: log.Printf("[metrics] counter %s: Add called with negative value %d, ignoring", c.name, n) +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:38: log.Printf("[metrics] counter %s=%d delta=%d", c.name, v, n) +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:73: log.Printf("[metrics] latency %s duration_ms=%.2f", lt.name, float64(d)/float64(time.Millisecond)) +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:124: log.Printf("[metrics] gauge %s=%d", g.name, v) +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:130: log.Printf("[metrics] gauge %s=%d", g.name, v) +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:137: log.Printf("[metrics] gauge %s=%d", g.name, v) +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:148:// Registry groups named metrics for a subsystem. +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:156:// NewRegistry creates a new metrics registry. +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:201:// Snapshot returns a point-in-time snapshot of all metrics. +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:223:// Snapshot is a point-in-time view of all registry metrics. +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:234:// Default returns the global default metrics registry. +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:242: // Session metrics +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:243: SessionCreated = "session.created" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:244: SessionDeleted = "session.deleted" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:245: SessionActive = "session.active_count" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:246: SessionPromptLatency = "session.prompt_latency" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:248: // Request metrics +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:253: // Tool metrics +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:254: ToolCallCount = "tool.call_count" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:255: ToolCallDuration = "tool.call_duration" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:256: ToolCallErrors = "tool.call_errors" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:258: // Permission metrics +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:261: // Agent metrics +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:265: // Output metrics +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:268: // Job metrics +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:274: // Connection metrics +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:283:func LogEvent(event, sessionID, requestID string, extra map[string]interface{}) { +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:286: if sessionID != "" { +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:287: pairs = append(pairs, "session_id", sessionID) +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:295: log.Printf("[metrics] event "+repeatFormat(len(pairs)/2), pairs...) +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser.go:15: MessageTool MessageType = "tool" +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser.go:37:// It keeps only thinking, tool, and assistant output blocks. +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser.go:155: case "tool": +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser.go:177: // Non-indented line ends thinking/tool block, starts output +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser.go:201:// parseMarker parses a line for transcript markers like [thinking], [tool], etc. +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser.go:226: case "thinking", "tool", "done", "client", "acpx": +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser_test.go:9: raw := `[acpx] session 78f133c0-862e-4ded-a22b-069338116f20 (8b2caf0c-dfe9-40c1-b718-700de1704a88) · /Users/chaoyuepan/ai/imclaw · agent needs reconnect +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser_test.go:12:[client] session/new (running) +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser_test.go:19:[tool] Skill (pending) +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser_test.go:22:[tool] Skill (completed) +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser_test.go:51: t.Fatalf("unexpected pending tool message: %#v", got[2]) +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser_test.go:54: t.Fatalf("expected completed tool message, got %#v", got[3]) +/Users/chaoyuepan/ai/imclaw/internal/transcript/parser_test.go:80: raw := `[acpx] session abc +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:8: "github.com/smallnest/imclaw/internal/metrics" +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:21: // Event holds a structured agent event (tool_start, output_final, etc.). +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:39:// StreamHub manages per-session fan-out of live stream events. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:40:// Each session can have multiple subscribers; events published to the hub +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:44: subscribers map[string]map[string]chan HubEvent // sessionID -> subscriberID -> channel +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:54:// Subscribe registers a subscriber for a session's live stream. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:55:// Returns a channel that will receive all events for that session. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:57:func (h *StreamHub) Subscribe(sessionID, subscriberID string) <-chan HubEvent { +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:63: if h.subscribers[sessionID] == nil { +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:64: h.subscribers[sessionID] = make(map[string]chan HubEvent) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:66: h.subscribers[sessionID][subscriberID] = ch +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:67: metrics.Default().Gauge(metrics.WSSubscribers).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:72:func (h *StreamHub) Unsubscribe(sessionID, subscriberID string) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:76: subs, ok := h.subscribers[sessionID] +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:83: metrics.Default().Gauge(metrics.WSSubscribers).Dec() +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:86: delete(h.subscribers, sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:90:// UnsubscribeAll removes all subscriptions for a subscriber across all sessions. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:95: for sessionID, subs := range h.subscribers { +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:99: metrics.Default().Gauge(metrics.WSSubscribers).Dec() +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:102: delete(h.subscribers, sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:107:// Publish sends an event to all subscribers of a session. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:109:func (h *StreamHub) Publish(sessionID string, evt HubEvent) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:113: subs := h.subscribers[sessionID] +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:131: log.Printf("[stream-hub] Dropping slow subscriber %s for session %s", subID, sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:134: metrics.Default().Counter(metrics.WSDroppedSubs).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:135: metrics.Default().Gauge(metrics.WSSubscribers).Dec() +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:139: delete(h.subscribers, sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:143:// SubscriberCount returns the number of active subscribers for a session. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:144:func (h *StreamHub) SubscriberCount(sessionID string) int { +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:147: return len(h.subscribers[sessionID]) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:150:// HasSubscribers returns true if the session has at least one subscriber. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:151:func (h *StreamHub) HasSubscribers(sessionID string) bool { +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:154: return len(h.subscribers[sessionID]) > 0 +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:11: "github.com/smallnest/imclaw/internal/session" +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:15: sessionMgr := session.NewManager() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:17: srv := NewServer(&Config{}, sessionMgr, agentMgr, job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:19: sess := sessionMgr.Create("cli", "", "sess-1", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:20: if _, ok := sessionMgr.RecordPrompt("cli", sess.ID, "req-1", "hello"); !ok { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:23: if _, ok := sessionMgr.RecordResult("cli", sess.ID, "req-1", "world"); !ok { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:27: listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:35: Sessions []session.SessionSummary `json:"sessions"` +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:44: detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/sess-1", nil) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:51: var detail session.Session +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:61: sessionMgr := session.NewManager() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:63: srv := NewServer(&Config{}, sessionMgr, agentMgr, job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:65: sess := sessionMgr.Create("cli", "", "sess-2", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:69: Method: "session.update", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:71: "session_id": sess.ID, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:79: updated, ok := resp.Result.(*session.Session) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:87: reloaded, exists := sessionMgr.Get("cli", sess.ID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:89: t.Fatal("expected session to exist after update") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:97: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:101: Method: "session.update", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:106: if resp.Error == nil || resp.Error.Message != "Missing required param: session_id" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:112: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:115: ID: "req-missing-session", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:116: Method: "session.update", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:118: "session_id": "does-not-exist", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:128: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:129: req := httptest.NewRequest(http.MethodGet, "/api/sessions/missing", nil) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:139: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:140: req := httptest.NewRequest(http.MethodGet, "/sessions/demo", nil) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:156: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:185: srv := NewServer(&Config{DevMode: true}, session.NewManager(), agent.NewManager(), job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:202: srv := NewServer(&Config{DevMode: false}, session.NewManager(), agent.NewManager(), job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:219: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:247: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_ui_test.go:259: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main_test.go:71: for _, tool := range allowed { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main_test.go:72: if tool == "Write" { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main_test.go:99: if got := params["allowed_tools"]; got != "Read" { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main_test.go:100: t.Fatalf("allowed_tools = %#v, want Read", got) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main_test.go:102: if got := params["denied_tools"]; got != "Grep" { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main_test.go:103: t.Fatalf("denied_tools = %#v, want Grep", got) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main_test.go:115: if !shouldSuggestApproveAll("User refused permission to run tool") { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main_test.go:162: "S": "session", +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:19: "github.com/smallnest/imclaw/internal/metrics" +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:40: TypeToolStart EventType = "tool_start" +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:41: TypeToolInput EventType = "tool_input" +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:42: TypeToolOutput EventType = "tool_output" +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:43: TypeToolEnd EventType = "tool_end" +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:68: // CreateSession creates a new agent session +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:69: CreateSession(ctx context.Context, sessionName string) (string, error) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:71: // EnsureSession ensures a session exists +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:72: EnsureSession(ctx context.Context, sessionName string) (string, error) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:75: Prompt(ctx context.Context, sessionID, prompt string) (string, error) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:78: PromptWithOptions(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (string, error) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:81: PromptStream(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (<-chan StreamChunk, error) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:113: // Allowed tools +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:116: // Denied tools +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:272:// CreateSession creates a new agent session +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:273:func (a *ACPXAgent) CreateSession(ctx context.Context, sessionName string) (string, error) { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:274: args := []string{a.agentType, "sessions", "new", "--name", sessionName} +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:275: log.Printf("[acpx] Creating session: %s", sessionName) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:287: SessionID string `json:"sessionId"` +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:295: log.Printf("[acpx] Parsed session ID from JSON: %s", result.SessionID) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:301: // Try to parse: [acpx] created session () +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:306: if strings.Contains(line, "created session") { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:312: log.Printf("[acpx] Parsed session ID from created line: %s", id) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:321: log.Printf("[acpx] Parsed session ID from created status: %s", id) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:327: log.Printf("[acpx] No sessionId in output, using session name: %s", sessionName) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:328: return sessionName, nil +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:331:// EnsureSession ensures a session exists +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:332:func (a *ACPXAgent) EnsureSession(ctx context.Context, sessionName string) (string, error) { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:333: args := []string{a.agentType, "sessions", "ensure", "--name", sessionName} +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:334: log.Printf("[acpx] Ensuring session: %s", sessionName) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:344: // Try to parse JSON format first: {"sessionId": "xxx"} +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:346: SessionID string `json:"sessionId"` +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:354: log.Printf("[acpx] Parsed session ID from JSON: %s", result.SessionID) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:360: // Try to parse: [acpx] created session () +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:365: // Format: [acpx] created session name (id) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:366: if strings.Contains(line, "created session") { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:373: log.Printf("[acpx] Parsed session ID from created line: %s", id) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:383: log.Printf("[acpx] Parsed session ID from created status: %s", id) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:389: // If no session ID found, return the session name +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:390: log.Printf("[acpx] No sessionId in output, using session name: %s", sessionName) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:391: return sessionName, nil +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:416:func (a *ACPXAgent) Prompt(ctx context.Context, sessionID, prompt string) (string, error) { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:417: return a.PromptWithOptions(ctx, sessionID, prompt, &PromptOptions{ +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:423:func (a *ACPXAgent) PromptWithOptions(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (string, error) { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:428: return a.doPrompt(ctx, sessionID, prompt, opts) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:432:func (a *ACPXAgent) PromptStream(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (<-chan StreamChunk, error) { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:437: return a.doPromptStream(ctx, sessionID, prompt, opts) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:440:func (a *ACPXAgent) doPrompt(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (string, error) { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:446: args, timeout, format := buildPromptArgs(a.agentType, sessionID, prompt, opts, policy, false) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:448: log.Printf("[acpx] Sending prompt to session %s (%s, format=%s)", sessionID, policy.Summary(), format) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:449: log.Printf("[acpx] Prompt: %s", metrics.Truncate(prompt, 200)) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:453: metrics.Default().Latency(metrics.AgentExecDuration).Since(start) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:457: metrics.Default().Counter(metrics.PermissionDenials).Inc() +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:458: metrics.LogEvent("permission.denied", sessionID, "", map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:469:func (a *ACPXAgent) doPromptStream(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (<-chan StreamChunk, error) { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:475: args, timeout, _ := buildPromptArgs(a.agentType, sessionID, prompt, opts, policy, true) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:477: log.Printf("[acpx] Streaming prompt to session %s (%s)", sessionID, policy.Summary()) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:478: log.Printf("[acpx] Prompt: %s", metrics.Truncate(prompt, 200)) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:484: metrics.Default().Counter(metrics.PermissionDenials).Inc() +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:485: metrics.LogEvent("permission.denied", sessionID, "", map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:490: metrics.Default().Counter(metrics.AgentExecFailures).Inc() +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:501: metrics.Default().Latency(metrics.AgentExecDuration).Since(streamStart) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:521:func buildPromptArgs(agentType, sessionID, prompt string, opts *PromptOptions, policy *permission.ResolvedPolicy, streaming bool) ([]string, int, string) { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:557: args = append(args, "--allowed-tools", allowed) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:575: args = append(args, agentType, "-s", sessionID, prompt) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:694: stateToolInput protocolState = "tool_input" +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:695: stateToolOutput protocolState = "tool_output" +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:708: toolName string +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:709: toolInput bytes.Buffer +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:710: toolOutput bytes.Buffer +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:785: case "tool": +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:808: p.appendLine(&p.toolInput, line) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:809: events = append(events, Event{Version: EventProtocolVersion, Type: TypeToolInput, Name: p.toolName, Input: line}) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:818: p.appendLine(&p.toolOutput, line) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:819: events = append(events, Event{Version: EventProtocolVersion, Type: TypeToolOutput, Name: p.toolName, Output: line}) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:848: p.toolName = strings.TrimSpace(strings.TrimSuffix(content, "(pending)")) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:849: p.toolInput.Reset() +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:850: p.toolOutput.Reset() +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:852: return append(events, Event{Version: EventProtocolVersion, Type: TypeToolStart, Name: p.toolName}) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:857: if p.toolName == "" { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:858: p.toolName = strings.TrimSpace(strings.TrimSuffix(content, "(completed)")) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:867: if p.toolName == "" { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:868: p.toolName = name +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:873: Name: p.toolName, +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:874: Input: strings.TrimSpace(p.toolInput.String()), +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:875: Output: strings.TrimSpace(p.toolOutput.String()), +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:876: Content: "tool execution failed", +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:913: if p.toolName == "" { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:922: Name: p.toolName, +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:923: Input: strings.TrimSpace(p.toolInput.String()), +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:924: Output: strings.TrimSpace(p.toolOutput.String()), +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:947: p.toolName = "" +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:948: p.toolInput.Reset() +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:949: p.toolOutput.Reset() +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:971: case "thinking", "tool", "done", "client", "acpx": +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:1038: strings.HasPrefix(trimmed, "[tool]") || +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:1089: if strings.Contains(line, "[acpx]") || strings.Contains(line, "session") { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:13: "github.com/smallnest/imclaw/internal/session" +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:17: return NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:22: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-rename", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:27: Method: "session.rename", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:29: "session_id": sess.ID, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:37: result, ok := resp.Result.(*session.Session) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:39: t.Fatalf("expected *session.Session result, got %T", resp.Result) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:52: Method: "session.rename", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:53: Params: map[string]interface{}{"session_id": "nonexistent"}, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:66: Method: "session.rename", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:68: "session_id": "nonexistent", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:73: t.Fatal("expected error for nonexistent session") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:79: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-tag", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:84: Method: "session.tag", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:86: "session_id": sess.ID, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:94: result := resp.Result.(*session.Session) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:102: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-untag", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:103: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "a") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:104: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "b") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:109: Method: "session.untag", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:111: "session_id": sess.ID, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:119: result := resp.Result.(*session.Session) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:131: Method: "session.untag", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:133: "session_id": "nonexistent", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:138: t.Fatal("expected error for nonexistent session") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:144: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-archive", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:149: Method: "session.archive", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:151: "session_id": sess.ID, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:158: result := resp.Result.(*session.Session) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:160: t.Fatal("expected session to be archived") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:166: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-unarchive", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:167: srv.sessionMgr.Archive(defaultSessionChannel, sess.ID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:172: Method: "session.unarchive", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:174: "session_id": sess.ID, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:181: result := resp.Result.(*session.Session) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:183: t.Fatal("expected session to be unarchived") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:189: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-export", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:190: srv.sessionMgr.Rename(defaultSessionChannel, sess.ID, "Exportable") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:191: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "test") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:196: Method: "session.export", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:198: "session_id": sess.ID, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:207: if result["session_id"] != sess.ID { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:208: t.Fatalf("expected session_id %q, got %v", sess.ID, result["session_id"]) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:218: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-export-md", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:219: srv.sessionMgr.Rename(defaultSessionChannel, sess.ID, "MD Export") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:224: Method: "session.export", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:226: "session_id": sess.ID, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:247: Method: "session.export", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:249: "session_id": "nonexistent", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:253: t.Fatal("expected error for nonexistent session") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:260: // Create and export a session +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:261: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-import", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:262: srv.sessionMgr.Rename(defaultSessionChannel, sess.ID, "Importable") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:263: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "import-tag") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:264: srv.sessionMgr.Archive(defaultSessionChannel, sess.ID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:266: exported, _ := srv.sessionMgr.Get(defaultSessionChannel, sess.ID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:267: data, err := session.ExportSession(exported, session.ExportJSON) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:272: // Delete the original session +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:273: srv.sessionMgr.Delete(defaultSessionChannel, sess.ID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:279: Method: "session.import", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:288: result := resp.Result.(*session.Session) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:293: t.Fatal("expected imported session to be archived") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:299: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-rmtag", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:300: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "keep") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:301: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "remove") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:302: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "also-remove") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:305: req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sess.ID, body) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:313: var result session.Session +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:324: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-settag", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:325: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "old") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:328: req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sess.ID, body) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:336: var result session.Session +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:347: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-clrtag", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:348: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "tag-a") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:349: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "tag-b") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:352: req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sess.ID, body) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:360: var result session.Session +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:371: srv.sessionMgr.Create(defaultSessionChannel, "", "s-arch1", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:372: srv.sessionMgr.Create(defaultSessionChannel, "", "s-arch2", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:373: srv.sessionMgr.Create(defaultSessionChannel, "", "s-arch3", "codex") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:374: srv.sessionMgr.Archive(defaultSessionChannel, "s-arch1") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:375: srv.sessionMgr.Archive(defaultSessionChannel, "s-arch3") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:377: req := httptest.NewRequest(http.MethodGet, "/api/sessions/archive/", nil) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:388: sessions := result["sessions"].([]interface{}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:389: if len(sessions) != 2 { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:390: t.Fatalf("expected 2 archived sessions, got %d", len(sessions)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:396: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-exp-api", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:397: srv.sessionMgr.Rename(defaultSessionChannel, sess.ID, "Export API Test") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:399: req := httptest.NewRequest(http.MethodGet, "/api/sessions/export/"+sess.ID+"?format=json", nil) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:419: sessionData := result["session"].(map[string]interface{}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:420: if sessionData["id"] != sess.ID { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:421: t.Fatalf("expected session ID %q, got %v", sess.ID, sessionData["id"]) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:427: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-exp-md-api", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:428: srv.sessionMgr.Rename(defaultSessionChannel, sess.ID, "MD API") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:430: req := httptest.NewRequest(http.MethodGet, "/api/sessions/export/"+sess.ID+"?format=markdown", nil) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:444: t.Fatalf("expected session name in markdown output, got:\n%s", body) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:450: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-imp-api", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:451: srv.sessionMgr.Rename(defaultSessionChannel, sess.ID, "Import API") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:452: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "api-test") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:455: full, _ := srv.sessionMgr.Get(defaultSessionChannel, sess.ID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:456: data, err := session.ExportSession(full, session.ExportJSON) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:462: srv.sessionMgr.Delete(defaultSessionChannel, sess.ID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:466: req := httptest.NewRequest(http.MethodPost, "/api/sessions/import", body) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:475: var result session.Session +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:491: req := httptest.NewRequest(http.MethodPost, "/api/sessions/import", body) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:503: srv.sessionMgr.Create(defaultSessionChannel, "", "s-active", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:504: srv.sessionMgr.Create(defaultSessionChannel, "", "s-to-archive", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:505: srv.sessionMgr.Archive(defaultSessionChannel, "s-to-archive") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:508: req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:516: sessions := result["sessions"].([]interface{}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:517: if len(sessions) != 1 { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:518: t.Fatalf("expected 1 active session (archived excluded), got %d", len(sessions)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:522: req = httptest.NewRequest(http.MethodGet, "/api/sessions?archived=true", nil) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:529: sessions = result["sessions"].([]interface{}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:530: if len(sessions) != 2 { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:531: t.Fatalf("expected 2 sessions with archived=true, got %d", len(sessions)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:537: srv.sessionMgr.Create(defaultSessionChannel, "", "s-t1", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:538: srv.sessionMgr.Create(defaultSessionChannel, "", "s-t2", "claude") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:539: srv.sessionMgr.AddTag(defaultSessionChannel, "s-t1", "important") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:540: srv.sessionMgr.AddTag(defaultSessionChannel, "s-t2", "review") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:542: req := httptest.NewRequest(http.MethodGet, "/api/sessions?tag=important", nil) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:550: sessions := result["sessions"].([]interface{}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:551: if len(sessions) != 1 { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:552: t.Fatalf("expected 1 session with tag 'important', got %d", len(sessions)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:557: mgr := session.NewManager() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:565: t.Fatalf("expected 1 active session, got %d", len(summaries)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:571: t.Fatalf("expected 2 sessions with archived, got %d", len(summaries)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:576: mgr := session.NewManager() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:584: t.Fatalf("expected 1 session with tag 'alpha', got %d", len(summaries)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_lifecycle_test.go:589: t.Fatalf("expected 0 sessions, got %d", len(summaries)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:19: "github.com/smallnest/imclaw/internal/metrics" +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:20: "github.com/smallnest/imclaw/internal/session" +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:43: sessionMgr *session.Manager +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:81:func NewServer(cfg *Config, sessionMgr *session.Manager, agentMgr *agent.Manager, jobMgr *job.Manager) *Server { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:84: sessionMgr: sessionMgr, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:117: mux.HandleFunc("/api/sessions", s.handleSessionsAPI) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:118: mux.HandleFunc("/api/sessions/export/", s.handleSessionExportAPI) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:119: mux.HandleFunc("/api/sessions/import", s.handleSessionImportAPI) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:120: mux.HandleFunc("/api/sessions/archive/", s.handleSessionArchiveAPI) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:121: mux.HandleFunc("/api/sessions/", s.handleSessionDetailAPI) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:248: var summaries []session.SessionSummary +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:250: summaries = s.sessionMgr.SummariesFiltered(tag, includeArchived) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:252: summaries = s.sessionMgr.Summaries() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:257: "sessions": summaries, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:262:func sessionChannelFromRequest(r *http.Request) string { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:269:func sessionChannelFromParams(params map[string]interface{}) string { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:279: sessionID := strings.TrimPrefix(r.URL.Path, "/api/sessions/") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:280: if sessionID == "" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:284: channel := sessionChannelFromRequest(r) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:288: sess, ok := s.sessionMgr.Get(channel, sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:310: updates := session.SessionUpdates{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:319: sess, ok := s.sessionMgr.ApplyUpdates(channel, sessionID, updates) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:354: sessionID := strings.TrimPrefix(r.URL.Path, "/api/sessions/export/") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:355: if sessionID == "" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:360: channel := sessionChannelFromRequest(r) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:361: sess, ok := s.sessionMgr.Get(channel, sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:372: data, err := session.ExportSession(sess, session.ExportFormat(format)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:382: w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.md", sessionID)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:385: w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=session-%s.json", sessionID)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:410: sess, err := session.ImportSession([]byte(req.Data)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:417: s.sessionMgr.Update(sess) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:429: // /api/sessions/archive/ lists archived sessions +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:430: archivedSessions := s.sessionMgr.ListArchived() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:431: summaries := make([]session.SessionSummary, 0, len(archivedSessions)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:437: "sessions": summaries, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:475: metrics.Default().Counter(metrics.JobSubmitted).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:544: // Create a temporary session for this job +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:548: // Create a unique session ID for this job +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:549: sessionID := uuid.NewString() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:550: agentSessionID, err := ag.EnsureSession(ctx, sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:552: return "", fmt.Errorf("failed to create agent session: %w", err) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:563: metrics.Default().Counter(metrics.JobFailed).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:564: metrics.Default().Counter(metrics.AgentExecFailures).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:565: metrics.LogEvent("job.failed", sessionID, "", map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:567: "error": metrics.Truncate(err.Error(), 200), +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:572: metrics.Default().Latency(metrics.JobDuration).Since(execStart) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:573: metrics.Default().Counter(metrics.JobCompleted).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:574: metrics.Default().Counter(metrics.OutputSize).Add(int64(len(response))) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:603: metrics.Default().Counter(metrics.RequestTotal).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:604: metrics.Default().Latency(metrics.RequestLatency).Since(start) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:606: metrics.Default().Counter(metrics.RequestErrors).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:637: metrics.Default().Gauge(metrics.WSConnections).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:643: "session_id": wsConn.ID, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:649: Method: "session.snapshot", +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:651: "sessions": s.sessionMgr.Summaries(), +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:689: metrics.Default().Gauge(metrics.WSConnections).Dec() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:723: if req.Method == "session.subscribe" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:738: case "session.init": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:740: case "session.new": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:742: case "session.get": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:744: case "session.list": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:746: case "session.update": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:748: case "session.delete": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:750: case "session.rename": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:752: case "session.tag": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:754: case "session.untag": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:756: case "session.archive": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:758: case "session.unarchive": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:760: case "session.subscribe": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:762: case "session.export": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:764: case "session.import": +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:786: metrics.Default().Latency(metrics.SessionPromptLatency).Since(askStart) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:800: sessionID := resolveSessionID(connID, getStringParam(params, "session_id")) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:803: sess := s.sessionMgr.GetOrCreate(defaultSessionChannel, "", sessionID, newAgent) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:805: s.sessionMgr.Update(sess) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:807: return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"agent": newAgent, "message": fmt.Sprintf("Switched to agent: %s", newAgent), "session_id": sess.ID}} +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:810: created := s.sessionMgr.Create(defaultSessionChannel, "", uuid.NewString(), agentType) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:812: return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"session_id": created.ID, "message": "New session created"}} +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:816: sess := s.prepareSession(sessionID, agentType) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:822: return s.rpcAgentError(sess.ID, req.ID, "Failed to create agent session", err) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:831: return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"content": response, "session_id": sess.ID, "agent": ag.Type()}} +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:839: sessionID := getStringParam(params, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:840: if sessionID == "" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:841: return missingParam(req.ID, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:844: return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &JSONRPCError{Code: -32601, Message: "session.subscribe requires WebSocket connection"}} +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:846: // Verify the session exists. +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:847: if _, ok := s.sessionMgr.Get(sessionChannelFromParams(params), sessionID); !ok { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:854: return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"subscribed": true, "session_id": sessionID}} +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:857:// handleWSSubscribe handles a session.subscribe request over WebSocket. +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:858:// It registers the connection as a subscriber to the session's live stream +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:866: sessionID := getStringParam(params, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:867: if sessionID == "" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:868: _ = conn.SendJSON(missingParam(req.ID, "session_id")) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:871: if _, ok := s.sessionMgr.Get(sessionChannelFromParams(params), sessionID); !ok { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:876: ch := s.hub.Subscribe(sessionID, conn.ID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:878: _ = conn.SendJSON(&JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"subscribed": true, "session_id": sessionID}}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:883: defer s.hub.Unsubscribe(sessionID, conn.ID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:884: s.relaySubscription(conn, sessionID, ch) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:889:func (s *Server) relaySubscription(conn *WSConnection, sessionID string, ch <-chan HubEvent) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:897: _ = conn.SendJSON(newEventNotification(sessionID, "", evt.Event)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:904: "session_id": evt.Chunk.SessionID, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:916: metrics.Default().Latency(metrics.SessionPromptLatency).Since(streamStart) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:932: sessionID := resolveSessionID(conn.ID, getStringParam(params, "session_id")) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:934: sess := s.prepareSession(sessionID, agentType) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:940: _ = conn.SendJSON(s.rpcAgentError(sess.ID, req.ID, "Failed to create agent session", err)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:963: toolStartTimes := make(map[string]time.Time) // tool name -> start time +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:966: // Track tool call metrics +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:969: toolStartTimes[evt.Name] = time.Now() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:970: metrics.Default().Counter(metrics.ToolCallCount).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:971: metrics.LogEvent("tool.start", sess.ID, req.ID, map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:972: "tool_name": evt.Name, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:975: if start, ok := toolStartTimes[evt.Name]; ok { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:976: metrics.Default().Latency(metrics.ToolCallDuration).Since(start) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:977: delete(toolStartTimes, evt.Name) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:979: metrics.LogEvent("tool.end", sess.ID, req.ID, map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:980: "tool_name": evt.Name, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:986: metrics.Default().Counter(metrics.ToolCallErrors).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1020: if err := conn.SendJSON(JSONRPCRequest{JSONRPC: "2.0", Method: "stream", Params: map[string]interface{}{"id": req.ID, "session_id": sess.ID, "type": chunk.Type, "content": cleanContent}}); err != nil { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1055: resultResp := JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"content": finalContent, "session_id": sess.ID, "agent": ag.Type()}} +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1060:func (s *Server) prepareSession(sessionID, agentType string) *session.Session { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1061: sess := s.sessionMgr.GetOrCreate(defaultSessionChannel, "", sessionID, agentType) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1064: s.sessionMgr.Update(sess) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1065: sess, _ = s.sessionMgr.Get(defaultSessionChannel, sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1071:func (s *Server) ensureAgentSession(sess *session.Session, ag agent.Agent, requestID string) (string, error) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1081: // ACPX prompts are addressed by the stable session name we chose (`sess.ID`). +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1082: // Preserve the returned internal session ID separately for observability. +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1085: s.sessionMgr.Update(sess) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1086: updated, _ := s.sessionMgr.Get(defaultSessionChannel, sess.ID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1088: log.Printf("[gateway] Created agent session, name=%s, acpx_id=%s, request=%s", sess.ID, id, requestID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1092:func (s *Server) rpcAgentError(sessionID, requestID, prefix string, err error) *JSONRPCResponse { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1094: s.recordError(sessionID, requestID, message) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1098:func (s *Server) recordPrompt(sessionID, requestID, prompt string) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1099: if sess, ok := s.sessionMgr.RecordPrompt(defaultSessionChannel, sessionID, requestID, prompt); ok { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1102: metrics.Default().Counter(metrics.SessionCreated).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1103: metrics.Default().Gauge(metrics.SessionActive).Set(int64(len(s.sessionMgr.Summaries()))) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1104: metrics.LogEvent("session.prompt", sessionID, requestID, map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1110:func (s *Server) recordEvent(sessionID, requestID string, evt agent.Event) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1111: if sess, ok := s.sessionMgr.RecordEvent(defaultSessionChannel, sessionID, requestID, evt); ok { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1117:func (s *Server) recordResult(sessionID, requestID, content string) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1118: if sess, ok := s.sessionMgr.RecordResult(defaultSessionChannel, sessionID, requestID, content); ok { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1121: metrics.Default().Counter(metrics.OutputSize).Add(int64(len(content))) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1122: metrics.LogEvent("session.result", sessionID, requestID, map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1128:func (s *Server) recordError(sessionID, requestID, message string) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1129: if sess, ok := s.sessionMgr.RecordError(defaultSessionChannel, sessionID, requestID, message); ok { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1132: metrics.Default().Counter(metrics.AgentExecFailures).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1133: metrics.LogEvent("session.error", sessionID, requestID, map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1134: "error_message": metrics.Truncate(message, 200), +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1139:func (s *Server) broadcastSession(sess *session.Session) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1143: s.broadcastJSON(JSONRPCRequest{JSONRPC: "2.0", Method: "session.updated", Params: map[string]interface{}{"session": sess.Summary()}}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1146:func (s *Server) broadcastActivity(sessionID string, activity session.Activity) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1147: s.broadcastJSON(JSONRPCRequest{JSONRPC: "2.0", Method: "session.activity", Params: map[string]interface{}{"session_id": sessionID, "activity": activity}}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1150:func (s *Server) broadcastSessionDeleted(sessionID string) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1151: s.broadcastJSON(JSONRPCRequest{JSONRPC: "2.0", Method: "session.deleted", Params: map[string]interface{}{"session_id": sessionID}}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1191:// filterTranscriptMarkers removes transcript marker lines like [thinking], [tool], [done], [acpx], [client] +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1200: strings.HasPrefix(trimmed, "[tool]") || +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1267:func newEventNotification(sessionID, id string, evt agent.Event) JSONRPCRequest { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1270: "session_id": sessionID, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1329: AllowedTools: getStringParam(params, "allowed_tools"), +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1330: DeniedTools: getStringParam(params, "denied_tools"), +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1358: sessionID := resolveSessionID(connID, getStringParam(params, "session_id")) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1360: sess := s.prepareSession(sessionID, agentType) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1361: return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"session_id": sess.ID, "agent": sess.AgentName, "created_at": sess.CreatedAt, "last_active": sess.LastActive}} +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1367: sessionID := getStringParam(params, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1368: if sessionID == "" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1369: sessionID = uuid.NewString() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1371: created := s.sessionMgr.Create(sessionChannelFromParams(params), "", sessionID, agentType) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1378: sessionID := resolveSessionID(connID, getStringParam(params, "session_id")) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1379: sess, ok := s.sessionMgr.Get(sessionChannelFromParams(params), sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1388: return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"sessions": s.sessionMgr.Summaries()}} +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1398: sessionID := getStringParam(params, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1399: if sessionID == "" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1400: return missingParam(req.ID, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1403: sess, ok := s.sessionMgr.Get(sessionChannelFromParams(params), sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1410: s.sessionMgr.Update(sess) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1411: updated, _ := s.sessionMgr.Get(sessionChannelFromParams(params), sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1423: sessionID := getStringParam(params, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1424: s.sessionMgr.Delete(sessionChannelFromParams(params), sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1425: s.broadcastSessionDeleted(sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1426: metrics.Default().Counter(metrics.SessionDeleted).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1427: metrics.Default().Gauge(metrics.SessionActive).Set(int64(len(s.sessionMgr.Summaries()))) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1437: sessionID := getStringParam(params, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1439: if sessionID == "" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1440: return missingParam(req.ID, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1445: sess, found := s.sessionMgr.Rename(sessionChannelFromParams(params), sessionID, name) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1459: sessionID := getStringParam(params, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1461: if sessionID == "" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1462: return missingParam(req.ID, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1467: sess, found := s.sessionMgr.AddTag(sessionChannelFromParams(params), sessionID, tag) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1481: sessionID := getStringParam(params, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1483: if sessionID == "" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1484: return missingParam(req.ID, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1489: sess, found := s.sessionMgr.RemoveTag(sessionChannelFromParams(params), sessionID, tag) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1503: sessionID := getStringParam(params, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1504: if sessionID == "" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1505: return missingParam(req.ID, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1507: sess, found := s.sessionMgr.Archive(sessionChannelFromParams(params), sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1521: sessionID := getStringParam(params, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1522: if sessionID == "" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1523: return missingParam(req.ID, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1525: sess, found := s.sessionMgr.Unarchive(sessionChannelFromParams(params), sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1539: sessionID := getStringParam(params, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1540: if sessionID == "" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1541: return missingParam(req.ID, "session_id") +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1543: sess, found := s.sessionMgr.Get(sessionChannelFromParams(params), sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1551: data, err := session.ExportSession(sess, session.ExportFormat(format)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1556: "session_id": sessionID, +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1572: sess, err := session.ImportSession([]byte(dataStr)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1576: s.sessionMgr.Update(sess) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:17: "github.com/smallnest/imclaw/internal/session" +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:93:// TestStreamHubUnsubscribeAll verifies cleanup across sessions. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:168:// TestStreamHubNoSubscribersPublishIsNoop verifies publishing to a session with no subscribers is safe. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:214:// It verifies that multiple WebSocket clients can subscribe to the same session and +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:217: sessionMgr := session.NewManager() +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:221: srv := NewServer(&Config{Host: "127.0.0.1", Port: 0}, sessionMgr, agentMgr, jobMgr) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:223: // Create a session manually. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:224: sess := sessionMgr.Create(defaultSessionChannel, "", "multi-sess", "stub") +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:247: // Read the "connected" and "session.snapshot" messages. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:255: // All clients subscribe to the same session. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:257: subReq := JSONRPCRequest{JSONRPC: "2.0", ID: fmt.Sprintf("sub-%d", i), Method: "session.subscribe", Params: map[string]interface{}{"session_id": sess.ID}} +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:379:// TestSessionSubscribeRPC tests the JSON-RPC session.subscribe method. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:381: sessionMgr := session.NewManager() +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:385: srv := NewServer(&Config{}, sessionMgr, agentMgr, jobMgr) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:386: sess := sessionMgr.Create(defaultSessionChannel, "", "rpc-sess", "stub") +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:388: // Test missing session_id. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:392: Method: "session.subscribe", +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:395: if resp.Error == nil || resp.Error.Message != "Missing required param: session_id" { +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:396: t.Fatalf("expected missing session_id error, got %v", resp) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:399: // Test non-existent session. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:403: Method: "session.subscribe", +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:404: Params: map[string]interface{}{"session_id": "nonexistent"}, +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:407: t.Fatalf("expected session not found error, got %v", resp) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:414: Method: "session.subscribe", +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:415: Params: map[string]interface{}{"session_id": sess.ID}, +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:507:// TestSessionSubscribeRequiresWebSocket verifies that session.subscribe fails for non-WS connections. +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:509: sessionMgr := session.NewManager() +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:512: srv := NewServer(&Config{}, sessionMgr, agentMgr, jobMgr) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:513: sessionMgr.Create(defaultSessionChannel, "", "test-sess", "stub") +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:519: Method: "session.subscribe", +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:520: Params: map[string]interface{}{"session_id": "test-sess"}, +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:531:// for different sessions do not interfere with each other. +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:29: sessionID = flag.StringP("session", "S", "", "Session ID to use (empty for auto-create)") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:57: // Export format (for session export command) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:58: exportFormat = flag.String("export-format", "json", "Export format for session export: json, markdown") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:61: listTag = flag.String("tag", "", "Filter session list by tag") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:62: listArchived = flag.Bool("archived", false, "Include archived sessions in list") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:71: allowedTools = flag.String("allowed-tools", "", "Allowed tool names (comma-separated)") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:72: deniedTools = flag.String("denied-tools", "", "Denied tool names (comma-separated)") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:75: maxTurns = flag.Int("max-turns", 0, "Maximum turns for the session") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:103: fmt.Fprintf(os.Stderr, " session Manage sessions\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:104: fmt.Fprintf(os.Stderr, " (session rename, session tag, session untag, session archive, session unarchive,\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:105: fmt.Fprintf(os.Stderr, " session export, session import, session list, session get)\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:143: // Handle session subcommands +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:144: if len(os.Args) > 1 && os.Args[1] == "session" { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:263: if *sessionID != "" { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:264: params["session_id"] = *sessionID +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:285: params["allowed_tools"] = allowed +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:288: params["denied_tools"] = *deniedTools +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:376: // Read welcome message to get session ID +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:383: if sid, ok := params["session_id"].(string); ok { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:517:// GetSession gets the current session info +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:518:func (c *Client) GetSession(sessionID string) (*JSONRPCResponse, error) { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:526: if sessionID != "" { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:527: params["session_id"] = sessionID +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:533: Method: "session.get", +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:549:// InitSession initializes or gets the session +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:550:func (c *Client) InitSession(sessionID, agentType string) (*JSONRPCResponse, error) { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:558: if sessionID != "" { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:559: params["session_id"] = sessionID +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:568: Method: "session.init", +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:618: strings.Contains(content, "[tool]") || +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:719: fmt.Fprintln(stderr, "Hint: this request likely needs broader tool permission. Retry with --permission-preset full-auto or --approve-all.") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:780: // Initialize session on startup +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:781: initResp, err := client.InitSession(*sessionID, *agentType) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:783: fmt.Fprintf(os.Stderr, "Error initializing session: %v\n", err) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:787: // Show session info +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:791: if sid, ok := result["session_id"].(string); ok { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:846: case line == "/session": +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:847: showSession(client, *sessionID) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:867: fmt.Println("New session created. Context cleared.") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:903: /new Create a new session (clear context) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:904: /session Show current session info +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:925:func showSession(client *Client, sessionID string) { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:926: resp, err := client.GetSession(sessionID) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:938: fmt.Println("No active session") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:942: // Parse session info +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:943: session, ok := resp.Result.(map[string]interface{}) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:950: fmt.Printf(" ID: %v\n", session["id"]) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:951: fmt.Printf(" Agent: %v\n", session["agent_name"]) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:952: fmt.Printf(" Agent Session: %v\n", session["agent_session"]) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:953: if createdAt, ok := session["created_at"].(string); ok { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:956: if lastActive, ok := session["last_active"].(string); ok { +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1295:// handleSessionCommand handles session subcommands +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1298: fmt.Fprintf(os.Stderr, "Error: session command requires an action\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1299: fmt.Fprintf(os.Stderr, "Usage: %s session [options]\n", os.Args[0]) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1306: // Rebuild args without the "session" prefix for flag parsing +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1307: sessionArgs := []string{os.Args[0]} +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1309: sessionArgs = append(sessionArgs, os.Args[3:]...) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1312: // Parse flags for session commands +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1313: flag.CommandLine.Parse(sessionArgs) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1335: fmt.Fprintf(os.Stderr, "Error: unknown session action: %s\n", action) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1343: fmt.Fprintf(os.Stderr, "Error: session rename requires a session ID and name\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1344: fmt.Fprintf(os.Stderr, "Usage: %s session rename \n", os.Args[0]) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1347: sessionID := os.Args[3] +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1353: req, err := http.NewRequest(http.MethodPatch, serverHTTP+"/api/sessions/"+sessionID, strings.NewReader(string(reqJSON))) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1361: fmt.Fprintf(os.Stderr, "Error renaming session: %v\n", err) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1368: fmt.Fprintf(os.Stderr, "Error: session not found\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1380: fmt.Printf("Session %s renamed to %q\n", sessionID, name) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1385: fmt.Fprintf(os.Stderr, "Error: session tag requires a session ID and tag\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1386: fmt.Fprintf(os.Stderr, "Usage: %s session tag \n", os.Args[0]) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1389: sessionID := os.Args[3] +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1395: req, err := http.NewRequest(http.MethodPatch, serverHTTP+"/api/sessions/"+sessionID, strings.NewReader(string(reqJSON))) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1403: fmt.Fprintf(os.Stderr, "Error tagging session: %v\n", err) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1410: fmt.Fprintf(os.Stderr, "Error: session not found\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1422: fmt.Printf("Tag %q added to session %s\n", tag, sessionID) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1427: fmt.Fprintf(os.Stderr, "Error: session untag requires a session ID and tag\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1428: fmt.Fprintf(os.Stderr, "Usage: %s session untag \n", os.Args[0]) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1431: sessionID := os.Args[3] +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1437: req, err := http.NewRequest(http.MethodPatch, serverHTTP+"/api/sessions/"+sessionID, strings.NewReader(string(reqJSON))) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1445: fmt.Fprintf(os.Stderr, "Error untagging session: %v\n", err) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1460: fmt.Printf("Tag %q removed from session %s\n", tag, sessionID) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1465: fmt.Fprintf(os.Stderr, "Error: session archive requires a session ID\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1466: fmt.Fprintf(os.Stderr, "Usage: %s session archive \n", os.Args[0]) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1469: sessionID := os.Args[3] +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1474: req, err := http.NewRequest(http.MethodPatch, serverHTTP+"/api/sessions/"+sessionID, strings.NewReader(string(reqJSON))) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1482: fmt.Fprintf(os.Stderr, "Error archiving session: %v\n", err) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1489: fmt.Fprintf(os.Stderr, "Error: session not found\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1501: fmt.Printf("Session %s archived\n", sessionID) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1506: fmt.Fprintf(os.Stderr, "Error: session unarchive requires a session ID\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1507: fmt.Fprintf(os.Stderr, "Usage: %s session unarchive \n", os.Args[0]) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1510: sessionID := os.Args[3] +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1515: req, err := http.NewRequest(http.MethodPatch, serverHTTP+"/api/sessions/"+sessionID, strings.NewReader(string(reqJSON))) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1523: fmt.Fprintf(os.Stderr, "Error unarchiving session: %v\n", err) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1530: fmt.Fprintf(os.Stderr, "Error: session not found\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1542: fmt.Printf("Session %s unarchived\n", sessionID) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1547: fmt.Fprintf(os.Stderr, "Error: session export requires a session ID\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1548: fmt.Fprintf(os.Stderr, "Usage: %s session export [--export-format json|markdown]\n", os.Args[0]) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1551: sessionID := os.Args[3] +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1559: req, err := http.NewRequest(http.MethodGet, serverHTTP+"/api/sessions/export/"+sessionID+"?format="+expFormat, nil) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1566: fmt.Fprintf(os.Stderr, "Error exporting session: %v\n", err) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1573: fmt.Fprintf(os.Stderr, "Error: session not found\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1586: fmt.Fprintf(os.Stderr, "Error: session import requires a file path\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1587: fmt.Fprintf(os.Stderr, "Usage: %s session import \n", os.Args[0]) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1602: resp, err := http.Post(serverHTTP+"/api/sessions/import", "application/json", strings.NewReader(string(reqJSON))) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1604: fmt.Fprintf(os.Stderr, "Error importing session: %v\n", err) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1641: listURL := serverHTTP + "/api/sessions" +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1655: fmt.Fprintf(os.Stderr, "Error fetching sessions: %v\n", err) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1666: Sessions []map[string]interface{} `json:"sessions"` +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1680: fmt.Println("No sessions found") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1710: fmt.Fprintf(os.Stderr, "Error: session get requires a session ID\n") +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1711: fmt.Fprintf(os.Stderr, "Usage: %s session get \n", os.Args[0]) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1714: sessionID := os.Args[3] +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1717: resp, err := http.Get(serverHTTP + "/api/sessions/" + sessionID) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1719: fmt.Fprintf(os.Stderr, "Error fetching session: %v\n", err) +/Users/chaoyuepan/ai/imclaw/cmd/imclaw-cli/main.go:1725: fmt.Fprintf(os.Stderr, "Error: session not found\n") + +codex +我已经确认当前分支就是 `feature/issue-11`,并且核心改动集中在 `internal/agent/agent.go`、`internal/gateway/server.go`、`internal/metrics/metrics.go` 和对应测试。下一步我会直接审阅这次提交的 diff,逐项检查行为正确性和测试覆盖。 +exec +/bin/zsh -lc 'git -C /Users/chaoyuepan/ai/imclaw show --stat --find-renames --find-copies 08f3bbc' in /Users/chaoyuepan/ai/imclaw +exec +/bin/zsh -lc 'git -C /Users/chaoyuepan/ai/imclaw show 08f3bbc -- internal/metrics/metrics.go internal/metrics/metrics_test.go internal/agent/agent.go internal/gateway/server.go' in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: +commit 08f3bbc7aef8733857b51c92d75f3fd8356a7424 +Author: chaoyuepan +Date: 2026-04-18 15:32:10 +0800 + + feat: add observability for sessions, tools, and agent execution (#11) + + Add metrics collection and structured logging via a new internal/metrics + package. All observability is log-based with no external tracing dependencies. + + New metrics: + - session.created, session.active_count, session.prompt_latency + - request.total, request.latency, request.errors + - tool.call_count, tool.call_duration, tool.call_errors + - permission.denials + - agent.exec_duration, agent.exec_failures + - output.size_bytes + - job.submitted/completed/failed/duration + - ws.active_connections/subscribers, ws.dropped_subscribers + + Structured event logs for: session.prompt, session.result, session.error, + tool.start, tool.end, job.failed, permission.denied + + .autoresearch/workflows/issue-11/log.md | 70 +++++++ + internal/agent/agent.go | 47 ++++- + internal/gateway/server.go | 82 +++++++- + internal/gateway/stream_hub.go | 6 + + internal/metrics/metrics.go | 320 ++++++++++++++++++++++++++++++++ + internal/metrics/metrics_test.go | 214 +++++++++++++++++++++ + 6 files changed, 734 insertions(+), 5 deletions(-) + + succeeded in 0ms: +commit 08f3bbc7aef8733857b51c92d75f3fd8356a7424 +Author: chaoyuepan +Date: 2026-04-18 15:32:10 +0800 + + feat: add observability for sessions, tools, and agent execution (#11) + + Add metrics collection and structured logging via a new internal/metrics + package. All observability is log-based with no external tracing dependencies. + + New metrics: + - session.created, session.active_count, session.prompt_latency + - request.total, request.latency, request.errors + - tool.call_count, tool.call_duration, tool.call_errors + - permission.denials + - agent.exec_duration, agent.exec_failures + - output.size_bytes + - job.submitted/completed/failed/duration + - ws.active_connections/subscribers, ws.dropped_subscribers + + Structured event logs for: session.prompt, session.result, session.error, + tool.start, tool.end, job.failed, permission.denied + +diff --git a/internal/agent/agent.go b/internal/agent/agent.go +index 21e8f7c..ed5c19c 100644 +--- a/internal/agent/agent.go ++++ b/internal/agent/agent.go +@@ -16,6 +16,7 @@ import ( + "unicode/utf8" + + "github.com/creack/pty" ++ "github.com/smallnest/imclaw/internal/metrics" + "github.com/smallnest/imclaw/internal/permission" + ) + +@@ -447,9 +448,19 @@ func (a *ACPXAgent) doPrompt(ctx context.Context, sessionID, prompt string, opts + log.Printf("[acpx] Sending prompt to session %s (%s, format=%s)", sessionID, policy.Summary(), format) + log.Printf("[acpx] Prompt: %s", truncate(prompt, 200)) + ++ start := time.Now() + response, err := a.runCommand(ctx, timeout, args...) ++ metrics.Default().Latency(metrics.AgentExecDuration).Since(start) + if err != nil { +- return "", fmt.Errorf("%s", annotatePermissionError(err.Error(), policy)) ++ annotated := annotatePermissionError(err.Error(), policy) ++ if isPermissionError(err.Error()) { ++ metrics.Default().Counter(metrics.PermissionDenials).Inc() ++ metrics.LogEvent("permission.denied", sessionID, "", map[string]interface{}{ ++ "agent": a.agentType, ++ "policy": policy.Summary(), ++ }) ++ } ++ return "", fmt.Errorf("%s", annotated) + } + return response, nil + } +@@ -466,7 +477,31 @@ func (a *ACPXAgent) doPromptStream(ctx context.Context, sessionID, prompt string + log.Printf("[acpx] Streaming prompt to session %s (%s)", sessionID, policy.Summary()) + log.Printf("[acpx] Prompt: %s", truncate(prompt, 200)) + +- return a.runCommandStream(ctx, timeout, policy, args...) ++ streamStart := time.Now() ++ ch, err := a.runCommandStream(ctx, timeout, policy, args...) ++ if err != nil { ++ if isPermissionError(err.Error()) { ++ metrics.Default().Counter(metrics.PermissionDenials).Inc() ++ metrics.LogEvent("permission.denied", sessionID, "", map[string]interface{}{ ++ "agent": a.agentType, ++ "policy": policy.Summary(), ++ }) ++ } ++ metrics.Default().Counter(metrics.AgentExecFailures).Inc() ++ return nil, err ++ } ++ ++ // Wrap the channel to track duration when stream completes ++ wrappedCh := make(chan StreamChunk, 200) ++ go func() { ++ defer close(wrappedCh) ++ for chunk := range ch { ++ wrappedCh <- chunk ++ } ++ metrics.Default().Latency(metrics.AgentExecDuration).Since(streamStart) ++ }() ++ ++ return wrappedCh, nil + } + + func resolvePromptPolicy(opts *PromptOptions) (*permission.ResolvedPolicy, error) { +@@ -545,13 +580,17 @@ func annotatePermissionError(message string, policy *permission.ResolvedPolicy) + if policy == nil { + return message + } +- lower := strings.ToLower(message) +- if strings.Contains(lower, "permission") || strings.Contains(lower, "exit status 5") || strings.Contains(lower, "refused") { ++ if isPermissionError(message) { + return fmt.Sprintf("permission policy denied request (%s): %s", policy.Summary(), message) + } + return message + } + ++func isPermissionError(message string) bool { ++ lower := strings.ToLower(message) ++ return strings.Contains(lower, "permission") || strings.Contains(lower, "exit status 5") || strings.Contains(lower, "refused") ++} ++ + // runCommandStream executes command and streams the output + func (a *ACPXAgent) runCommandStream(ctx context.Context, timeout int, policy *permission.ResolvedPolicy, args ...string) (<-chan StreamChunk, error) { + if timeout == 0 { +diff --git a/internal/gateway/server.go b/internal/gateway/server.go +index b2cb1c7..9eb1e86 100644 +--- a/internal/gateway/server.go ++++ b/internal/gateway/server.go +@@ -16,6 +16,7 @@ import ( + "github.com/smallnest/imclaw/internal/agent" + "github.com/smallnest/imclaw/internal/event" + "github.com/smallnest/imclaw/internal/job" ++ "github.com/smallnest/imclaw/internal/metrics" + "github.com/smallnest/imclaw/internal/session" + ) + +@@ -471,6 +472,7 @@ func (s *Server) handleJobsAPI(w http.ResponseWriter, r *http.Request) { + timeout := time.Duration(req.Timeout) * time.Second + + submittedJob := s.jobMgr.Submit(req.Prompt, req.AgentName, timeout) ++ metrics.Default().Counter(metrics.JobSubmitted).Inc() + + // Start executing the job in background + go job.ExecuteJob(context.Background(), s.jobMgr, submittedJob.ID, s.executeJobPrompt) +@@ -537,6 +539,8 @@ func (s *Server) handleJobDetailAPI(w http.ResponseWriter, r *http.Request) { + + // executeJobPrompt executes a job prompt using the agent manager + func (s *Server) executeJobPrompt(ctx context.Context, prompt string, logFn func(level, msg string)) (string, error) { ++ execStart := time.Now() ++ + // Create a temporary session for this job + agentType := "acpx" + ag := s.agentMgr.GetOrCreate(agentType) +@@ -556,9 +560,19 @@ func (s *Server) executeJobPrompt(ctx context.Context, prompt string, logFn func + }) + if err != nil { + logFn("error", fmt.Sprintf("Execution failed: %v", err)) ++ metrics.Default().Counter(metrics.JobFailed).Inc() ++ metrics.Default().Counter(metrics.AgentExecFailures).Inc() ++ metrics.LogEvent("job.failed", sessionID, "", map[string]interface{}{ ++ "agent": agentType, ++ "error": truncate(err.Error(), 200), ++ }) + return "", err + } + ++ metrics.Default().Latency(metrics.JobDuration).Since(execStart) ++ metrics.Default().Counter(metrics.JobCompleted).Inc() ++ metrics.Default().Counter(metrics.OutputSize).Add(int64(len(response))) ++ + logFn("info", "Execution completed successfully") + return response, nil + } +@@ -583,8 +597,17 @@ func (s *Server) handleJSONRPC(w http.ResponseWriter, r *http.Request) { + return + } + ++ start := time.Now() ++ resp := s.handleRPCRequest("", &req) ++ ++ metrics.Default().Counter(metrics.RequestTotal).Inc() ++ metrics.Default().Latency(metrics.RequestLatency).Since(start) ++ if resp.Error != nil { ++ metrics.Default().Counter(metrics.RequestErrors).Inc() ++ } ++ + w.Header().Set("Content-Type", "application/json") +- _ = json.NewEncoder(w).Encode(s.handleRPCRequest("", &req)) ++ _ = json.NewEncoder(w).Encode(resp) + } + + func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { +@@ -611,6 +634,7 @@ func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + s.connectionsMu.Lock() + s.connections[wsConn.ID] = wsConn + s.connectionsMu.Unlock() ++ metrics.Default().Gauge(metrics.WSConnections).Inc() + + _ = wsConn.SendJSON(JSONRPCRequest{ + JSONRPC: "2.0", +@@ -662,6 +686,7 @@ func (s *Server) handleWSMessages(conn *WSConnection) { + s.connectionsMu.Lock() + delete(s.connections, conn.ID) + s.connectionsMu.Unlock() ++ metrics.Default().Gauge(metrics.WSConnections).Dec() + }() + + _ = conn.SetReadDeadline(time.Now().Add(wsPongWait)) +@@ -756,6 +781,11 @@ func (s *Server) handleRPCRequest(connID string, req *JSONRPCRequest) *JSONRPCRe + } + + func (s *Server) handleAsk(connID string, req *JSONRPCRequest) *JSONRPCResponse { ++ askStart := time.Now() ++ defer func() { ++ metrics.Default().Latency(metrics.SessionPromptLatency).Since(askStart) ++ }() ++ + params, ok := req.Params.(map[string]interface{}) + if !ok { + return invalidParams(req.ID) +@@ -881,6 +911,11 @@ func (s *Server) relaySubscription(conn *WSConnection, sessionID string, ch <-ch + } + + func (s *Server) handleAskStream(conn *WSConnection, req *JSONRPCRequest) { ++ streamStart := time.Now() ++ defer func() { ++ metrics.Default().Latency(metrics.SessionPromptLatency).Since(streamStart) ++ }() ++ + params, ok := req.Params.(map[string]interface{}) + if !ok { + _ = conn.SendJSON(invalidParams(req.ID)) +@@ -925,8 +960,33 @@ func (s *Server) handleAskStream(conn *WSConnection, req *JSONRPCRequest) { + parser := event.NewParser() + sawNativeEvents := false + sawErrorEvent := false ++ toolStartTimes := make(map[string]time.Time) // tool name -> start time + + publishAndSend := func(evt agent.Event) { ++ // Track tool call metrics ++ switch evt.Type { ++ case agent.TypeToolStart: ++ toolStartTimes[evt.Name] = time.Now() ++ metrics.Default().Counter(metrics.ToolCallCount).Inc() ++ metrics.LogEvent("tool.start", sess.ID, req.ID, map[string]interface{}{ ++ "tool_name": evt.Name, ++ }) ++ case agent.TypeToolEnd: ++ if start, ok := toolStartTimes[evt.Name]; ok { ++ metrics.Default().Latency(metrics.ToolCallDuration).Since(start) ++ delete(toolStartTimes, evt.Name) ++ } ++ metrics.LogEvent("tool.end", sess.ID, req.ID, map[string]interface{}{ ++ "tool_name": evt.Name, ++ "input_size": len(evt.Input), ++ "output_size": len(evt.Output), ++ }) ++ case agent.TypeError: ++ if evt.Name != "" { ++ metrics.Default().Counter(metrics.ToolCallErrors).Inc() ++ } ++ } ++ + s.recordEvent(sess.ID, req.ID, evt) + // Fan-out to all hub subscribers. + s.hub.Publish(sess.ID, HubEvent{Event: evt}) +@@ -1039,6 +1099,11 @@ func (s *Server) recordPrompt(sessionID, requestID, prompt string) { + if sess, ok := s.sessionMgr.RecordPrompt(defaultSessionChannel, sessionID, requestID, prompt); ok { + s.broadcastSession(sess) + s.broadcastActivity(sess.ID, sess.Activity[len(sess.Activity)-1]) ++ metrics.Default().Counter(metrics.SessionCreated).Inc() ++ metrics.Default().Gauge(metrics.SessionActive).Set(int64(len(s.sessionMgr.Summaries()))) ++ metrics.LogEvent("session.prompt", sessionID, requestID, map[string]interface{}{ ++ "prompt_length": len(prompt), ++ }) + } + } + +@@ -1053,6 +1118,10 @@ func (s *Server) recordResult(sessionID, requestID, content string) { + if sess, ok := s.sessionMgr.RecordResult(defaultSessionChannel, sessionID, requestID, content); ok { + s.broadcastSession(sess) + s.broadcastActivity(sess.ID, sess.Activity[len(sess.Activity)-1]) ++ metrics.Default().Counter(metrics.OutputSize).Add(int64(len(content))) ++ metrics.LogEvent("session.result", sessionID, requestID, map[string]interface{}{ ++ "output_length": len(content), ++ }) + } + } + +@@ -1060,6 +1129,10 @@ func (s *Server) recordError(sessionID, requestID, message string) { + if sess, ok := s.sessionMgr.RecordError(defaultSessionChannel, sessionID, requestID, message); ok { + s.broadcastSession(sess) + s.broadcastActivity(sess.ID, sess.Activity[len(sess.Activity)-1]) ++ metrics.Default().Counter(metrics.AgentExecFailures).Inc() ++ metrics.LogEvent("session.error", sessionID, requestID, map[string]interface{}{ ++ "error_message": truncate(message, 200), ++ }) + } + } + +@@ -1272,6 +1345,13 @@ func resolveSessionID(connID, specifiedSessionID string) string { + return "default" + } + ++func truncate(s string, maxLen int) string { ++ if len(s) <= maxLen { ++ return s ++ } ++ return s[:maxLen] + "..." ++} ++ + func invalidParams(id string) *JSONRPCResponse { + return &JSONRPCResponse{JSONRPC: "2.0", ID: id, Error: &JSONRPCError{Code: -32602, Message: "Invalid params"}} + } +diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go +new file mode 100644 +index 0000000..35c599c +--- /dev/null ++++ b/internal/metrics/metrics.go +@@ -0,0 +1,320 @@ ++// Package metrics provides lightweight observability for IMClaw via structured logs. ++// ++// All metrics are emitted through Go's standard log package with a consistent ++// [metrics] prefix and structured key=value pairs, making them grep-friendly ++// and easy to feed into log aggregation pipelines. ++package metrics ++ ++import ( ++ "log" ++ "sort" ++ "sync" ++ "sync/atomic" ++ "time" ++) ++ ++// ---- Counters ---- ++ ++// Counter is an atomically incremented metric counter. ++type Counter struct { ++ name string ++ count atomic.Int64 ++} ++ ++// Inc increments the counter by 1. ++func (c *Counter) Inc() { ++ v := c.count.Add(1) ++ log.Printf("[metrics] counter %s=%d", c.name, v) ++} ++ ++// Add increments the counter by n. ++func (c *Counter) Add(n int64) { ++ v := c.count.Add(n) ++ log.Printf("[metrics] counter %s=%d delta=%d", c.name, v, n) ++} ++ ++// Value returns the current counter value. ++func (c *Counter) Value() int64 { ++ return c.count.Load() ++} ++ ++// ---- Latency Tracker ---- ++ ++// LatencyTracker measures duration distributions for named operations. ++type LatencyTracker struct { ++ name string ++ mu sync.Mutex ++ samples []time.Duration ++} ++ ++// Observe records a duration and emits a structured log line. ++func (lt *LatencyTracker) Observe(d time.Duration) { ++ lt.mu.Lock() ++ lt.samples = append(lt.samples, d) ++ lt.mu.Unlock() ++ ++ log.Printf("[metrics] latency %s duration_ms=%.2f", lt.name, float64(d)/float64(time.Millisecond)) ++} ++ ++// Since returns a duration from the given start time. It is a convenience ++// wrapper intended for one-line usage: defer tracker.Since(time.Now()) ++func (lt *LatencyTracker) Since(start time.Time) { ++ lt.Observe(time.Since(start)) ++} ++ ++// Summary returns aggregate statistics (count, min, max, avg, p50, p95, p99). ++// Returns zero values if no samples have been recorded. ++func (lt *LatencyTracker) Summary() LatencySummary { ++ lt.mu.Lock() ++ samples := make([]time.Duration, len(lt.samples)) ++ copy(samples, lt.samples) ++ lt.mu.Unlock() ++ ++ return computeSummary(lt.name, samples) ++} ++ ++// LatencySummary holds aggregate latency statistics. ++type LatencySummary struct { ++ Name string ++ Count int ++ Min time.Duration ++ Max time.Duration ++ Avg time.Duration ++ P50 time.Duration ++ P95 time.Duration ++ P99 time.Duration ++ Samples []time.Duration ++} ++ ++// ---- Gauge ---- ++ ++// Gauge tracks a point-in-time integer value. ++type Gauge struct { ++ name string ++ value atomic.Int64 ++} ++ ++// Set updates the gauge value. ++func (g *Gauge) Set(v int64) { ++ g.value.Store(v) ++ log.Printf("[metrics] gauge %s=%d", g.name, v) ++} ++ ++// Inc increments the gauge by 1. ++func (g *Gauge) Inc() int64 { ++ v := g.value.Add(1) ++ log.Printf("[metrics] gauge %s=%d", g.name, v) ++ return v ++} ++ ++// Dec decrements the gauge by 1. ++func (g *Gauge) Dec() int64 { ++ v := g.value.Add(-1) ++ log.Printf("[metrics] gauge %s=%d", g.name, v) ++ return v ++} ++ ++// Value returns the current gauge value. ++func (g *Gauge) Value() int64 { ++ return g.value.Load() ++} ++ ++// ---- Registry ---- ++ ++// Registry groups named metrics for a subsystem. ++type Registry struct { ++ mu sync.Mutex ++ counters map[string]*Counter ++ latency map[string]*LatencyTracker ++ gauges map[string]*Gauge ++} ++ ++// NewRegistry creates a new metrics registry. ++func NewRegistry() *Registry { ++ return &Registry{ ++ counters: make(map[string]*Counter), ++ latency: make(map[string]*LatencyTracker), ++ gauges: make(map[string]*Gauge), ++ } ++} ++ ++// Counter returns (or creates) a counter by name. ++func (r *Registry) Counter(name string) *Counter { ++ r.mu.Lock() ++ defer r.mu.Unlock() ++ if c, ok := r.counters[name]; ok { ++ return c ++ } ++ c := &Counter{name: name} ++ r.counters[name] = c ++ return c ++} ++ ++// Latency returns (or creates) a latency tracker by name. ++func (r *Registry) Latency(name string) *LatencyTracker { ++ r.mu.Lock() ++ defer r.mu.Unlock() ++ if lt, ok := r.latency[name]; ok { ++ return lt ++ } ++ lt := &LatencyTracker{name: name} ++ r.latency[name] = lt ++ return lt ++} ++ ++// Gauge returns (or creates) a gauge by name. ++func (r *Registry) Gauge(name string) *Gauge { ++ r.mu.Lock() ++ defer r.mu.Unlock() ++ if g, ok := r.gauges[name]; ok { ++ return g ++ } ++ g := &Gauge{name: name} ++ r.gauges[name] = g ++ return g ++} ++ ++// Snapshot returns a point-in-time snapshot of all metrics. ++func (r *Registry) Snapshot() Snapshot { ++ r.mu.Lock() ++ defer r.mu.Unlock() ++ ++ snap := Snapshot{ ++ Counters: make(map[string]int64, len(r.counters)), ++ Gauges: make(map[string]int64, len(r.gauges)), ++ Latency: make(map[string]LatencySummary, len(r.latency)), ++ } ++ for name, c := range r.counters { ++ snap.Counters[name] = c.Value() ++ } ++ for name, g := range r.gauges { ++ snap.Gauges[name] = g.Value() ++ } ++ for name, lt := range r.latency { ++ snap.Latency[name] = lt.Summary() ++ } ++ return snap ++} ++ ++// Snapshot is a point-in-time view of all registry metrics. ++type Snapshot struct { ++ Counters map[string]int64 ++ Gauges map[string]int64 ++ Latency map[string]LatencySummary ++} ++ ++// ---- Global default registry ---- ++ ++var defaultRegistry = NewRegistry() ++ ++// Default returns the global default metrics registry. ++func Default() *Registry { ++ return defaultRegistry ++} ++ ++// Predefined metric names following dashboard-friendly conventions. ++// Naming: . ++const ( ++ // Session metrics ++ SessionCreated = "session.created" ++ SessionDeleted = "session.deleted" ++ SessionActive = "session.active_count" ++ SessionPromptLatency = "session.prompt_latency" ++ ++ // Request metrics ++ RequestTotal = "request.total" ++ RequestLatency = "request.latency" ++ RequestErrors = "request.errors" ++ ++ // Tool metrics ++ ToolCallCount = "tool.call_count" ++ ToolCallDuration = "tool.call_duration" ++ ToolCallErrors = "tool.call_errors" ++ ++ // Permission metrics ++ PermissionDenials = "permission.denials" ++ ++ // Agent metrics ++ AgentExecDuration = "agent.exec_duration" ++ AgentExecFailures = "agent.exec_failures" ++ ++ // Output metrics ++ OutputSize = "output.size_bytes" ++ ++ // Job metrics ++ JobSubmitted = "job.submitted" ++ JobCompleted = "job.completed" ++ JobFailed = "job.failed" ++ JobDuration = "job.duration" ++ ++ // Connection metrics ++ WSConnections = "ws.active_connections" ++ WSSubscribers = "ws.active_subscribers" ++ WSDroppedSubs = "ws.dropped_subscribers" ++) ++ ++// ---- Event logging helpers ---- ++ ++// LogEvent emits a structured event log line for key operational events. ++func LogEvent(event, sessionID, requestID string, extra map[string]interface{}) { ++ pairs := make([]interface{}, 0, 2+len(extra)*2) ++ pairs = append(pairs, "event", event) ++ if sessionID != "" { ++ pairs = append(pairs, "session_id", sessionID) ++ } ++ if requestID != "" { ++ pairs = append(pairs, "request_id", requestID) ++ } ++ for k, v := range extra { ++ pairs = append(pairs, k, v) ++ } ++ log.Printf("[metrics] event "+repeatFormat(len(pairs)/2), pairs...) ++} ++ ++func repeatFormat(n int) string { ++ const pair = " %s=%v" ++ result := "" ++ for i := 0; i < n; i++ { ++ result += pair ++ } ++ return result ++} ++ ++// ---- Internal helpers ---- ++ ++func computeSummary(name string, samples []time.Duration) LatencySummary { ++ s := LatencySummary{ ++ Name: name, ++ Count: len(samples), ++ Samples: samples, ++ } ++ if len(samples) == 0 { ++ return s ++ } ++ ++ // Sort a copy to compute percentiles ++ sorted := make([]time.Duration, len(samples)) ++ copy(sorted, samples) ++ sort.Sort(sortableDurations(sorted)) ++ ++ n := len(sorted) ++ s.Min = sorted[0] ++ s.Max = sorted[n-1] ++ ++ var total time.Duration ++ for _, d := range sorted { ++ total += d ++ } ++ s.Avg = total / time.Duration(n) ++ s.P50 = sorted[n*50/100] ++ s.P95 = sorted[n*95/100] ++ s.P99 = sorted[n*99/100] ++ ++ return s ++} ++ ++type sortableDurations []time.Duration ++ ++func (d sortableDurations) Len() int { return len(d) } ++func (d sortableDurations) Less(i, j int) bool { return d[i] < d[j] } ++func (d sortableDurations) Swap(i, j int) { d[i], d[j] = d[j], d[i] } +diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go +new file mode 100644 +index 0000000..db84e8c +--- /dev/null ++++ b/internal/metrics/metrics_test.go +@@ -0,0 +1,214 @@ ++package metrics ++ ++import ( ++ "testing" ++ "time" ++) ++ ++func TestCounterInc(t *testing.T) { ++ r := NewRegistry() ++ c := r.Counter("test.counter") ++ ++ if v := c.Value(); v != 0 { ++ t.Fatalf("expected initial value 0, got %d", v) ++ } ++ c.Inc() ++ if v := c.Value(); v != 1 { ++ t.Fatalf("expected 1 after Inc, got %d", v) ++ } ++} ++ ++func TestCounterAdd(t *testing.T) { ++ r := NewRegistry() ++ c := r.Counter("test.counter_add") ++ ++ c.Add(5) ++ if v := c.Value(); v != 5 { ++ t.Fatalf("expected 5 after Add(5), got %d", v) ++ } ++ c.Add(3) ++ if v := c.Value(); v != 8 { ++ t.Fatalf("expected 8 after Add(3), got %d", v) ++ } ++} ++ ++func TestCounterRegistryReturnsSame(t *testing.T) { ++ r := NewRegistry() ++ c1 := r.Counter("same_name") ++ c2 := r.Counter("same_name") ++ ++ c1.Inc() ++ if v := c2.Value(); v != 1 { ++ t.Fatalf("expected shared counter to be 1, got %d", v) ++ } ++} ++ ++func TestGaugeSet(t *testing.T) { ++ r := NewRegistry() ++ g := r.Gauge("test.gauge") ++ ++ g.Set(42) ++ if v := g.Value(); v != 42 { ++ t.Fatalf("expected 42 after Set, got %d", v) ++ } ++ g.Set(100) ++ if v := g.Value(); v != 100 { ++ t.Fatalf("expected 100 after Set, got %d", v) ++ } ++} ++ ++func TestGaugeIncDec(t *testing.T) { ++ r := NewRegistry() ++ g := r.Gauge("test.gauge_incdec") ++ ++ g.Inc() ++ g.Inc() ++ if v := g.Value(); v != 2 { ++ t.Fatalf("expected 2 after two Inc, got %d", v) ++ } ++ g.Dec() ++ if v := g.Value(); v != 1 { ++ t.Fatalf("expected 1 after Dec, got %d", v) ++ } ++} ++ ++func TestLatencyTrackerObserve(t *testing.T) { ++ r := NewRegistry() ++ lt := r.Latency("test.latency") ++ ++ lt.Observe(100 * time.Millisecond) ++ lt.Observe(200 * time.Millisecond) ++ lt.Observe(300 * time.Millisecond) ++ ++ s := lt.Summary() ++ if s.Count != 3 { ++ t.Fatalf("expected 3 samples, got %d", s.Count) ++ } ++ if s.Min != 100*time.Millisecond { ++ t.Fatalf("expected min 100ms, got %v", s.Min) ++ } ++ if s.Max != 300*time.Millisecond { ++ t.Fatalf("expected max 300ms, got %v", s.Max) ++ } ++ if s.Avg != 200*time.Millisecond { ++ t.Fatalf("expected avg 200ms, got %v", s.Avg) ++ } ++} ++ ++func TestLatencyTrackerSince(t *testing.T) { ++ r := NewRegistry() ++ lt := r.Latency("test.since") ++ ++ start := time.Now().Add(-50 * time.Millisecond) ++ lt.Since(start) ++ ++ s := lt.Summary() ++ if s.Count != 1 { ++ t.Fatalf("expected 1 sample, got %d", s.Count) ++ } ++ if s.Min < 40*time.Millisecond { ++ t.Fatalf("expected min >= 40ms, got %v", s.Min) ++ } ++} ++ ++func TestLatencyTrackerPercentiles(t *testing.T) { ++ r := NewRegistry() ++ lt := r.Latency("test.percentiles") ++ ++ // Add 100 samples: 1ms, 2ms, ..., 100ms ++ for i := 1; i <= 100; i++ { ++ lt.Observe(time.Duration(i) * time.Millisecond) ++ } ++ ++ s := lt.Summary() ++ if s.Count != 100 { ++ t.Fatalf("expected 100 samples, got %d", s.Count) ++ } ++ if s.P50 < 45*time.Millisecond || s.P50 > 55*time.Millisecond { ++ t.Fatalf("expected P50 around 50ms, got %v", s.P50) ++ } ++ if s.P95 < 90*time.Millisecond { ++ t.Fatalf("expected P95 >= 90ms, got %v", s.P95) ++ } ++ if s.P99 < 95*time.Millisecond { ++ t.Fatalf("expected P99 >= 95ms, got %v", s.P99) ++ } ++} ++ ++func TestLatencyTrackerEmptySummary(t *testing.T) { ++ r := NewRegistry() ++ lt := r.Latency("test.empty") ++ ++ s := lt.Summary() ++ if s.Count != 0 { ++ t.Fatalf("expected 0 samples, got %d", s.Count) ++ } ++ if s.Min != 0 || s.Max != 0 || s.Avg != 0 { ++ t.Fatalf("expected zero durations for empty tracker, got min=%v max=%v avg=%v", s.Min, s.Max, s.Avg) ++ } ++} ++ ++func TestRegistrySnapshot(t *testing.T) { ++ r := NewRegistry() ++ c := r.Counter("snap.counter") ++ g := r.Gauge("snap.gauge") ++ lt := r.Latency("snap.latency") ++ ++ c.Inc() ++ c.Inc() ++ g.Set(42) ++ lt.Observe(10 * time.Millisecond) ++ ++ snap := r.Snapshot() ++ if snap.Counters["snap.counter"] != 2 { ++ t.Fatalf("expected counter=2, got %d", snap.Counters["snap.counter"]) ++ } ++ if snap.Gauges["snap.gauge"] != 42 { ++ t.Fatalf("expected gauge=42, got %d", snap.Gauges["snap.gauge"]) ++ } ++ latSummary, ok := snap.Latency["snap.latency"] ++ if !ok { ++ t.Fatal("expected latency summary in snapshot") ++ } ++ if latSummary.Count != 1 { ++ t.Fatalf("expected 1 latency sample, got %d", latSummary.Count) ++ } ++} ++ ++func TestDefaultRegistry(t *testing.T) { ++ d := Default() ++ if d == nil { ++ t.Fatal("expected non-nil default registry") ++ } ++ // Same instance ++ if Default() != d { ++ t.Fatal("expected same default registry instance") ++ } ++} ++ ++func TestLogEvent(t *testing.T) { ++ // Just verify it doesn't panic ++ LogEvent("test.event", "sess-1", "req-1", map[string]interface{}{ ++ "key": "value", ++ }) ++ LogEvent("test.event", "", "", nil) ++} ++ ++func TestPredefinedMetricNames(t *testing.T) { ++ // Verify predefined names are non-empty and follow naming convention ++ names := []string{ ++ SessionCreated, SessionDeleted, SessionActive, SessionPromptLatency, ++ RequestTotal, RequestLatency, RequestErrors, ++ ToolCallCount, ToolCallDuration, ToolCallErrors, ++ PermissionDenials, ++ AgentExecDuration, AgentExecFailures, ++ OutputSize, ++ JobSubmitted, JobCompleted, JobFailed, JobDuration, ++ WSConnections, WSSubscribers, WSDroppedSubs, ++ } ++ for _, name := range names { ++ if name == "" { ++ t.Fatal("empty metric name") ++ } ++ } ++} + +codex +我正在补看 `stream_hub.go` 与全局 metrics 的实际调用点,重点检查计数语义是否准确(例如 session.created、tool.errors、ws gauges),避免只看单个文件导致误判。 +exec +/bin/zsh -lc "nl -ba /Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go | sed -n '1,260p'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: + 1 package gateway + 2 + 3 import ( + 4 "log" + 5 "sync" + 6 + 7 "github.com/smallnest/imclaw/internal/agent" + 8 "github.com/smallnest/imclaw/internal/metrics" + 9 ) + 10 + 11 const ( + 12 // subscriberBufSize is the per-subscriber channel buffer size. + 13 // When full, slow subscribers are dropped to avoid blocking the publisher. + 14 subscriberBufSize = 256 + 15 ) + 16 + 17 // HubEvent wraps an event or raw stream chunk for fan-out delivery. + 18 type HubEvent struct { + 19 // Exactly one field is set per message. + 20 + 21 // Event holds a structured agent event (tool_start, output_final, etc.). + 22 Event agent.Event + 23 // Chunk holds a raw stream content chunk. + 24 Chunk StreamChunkMsg + 25 // Result holds the final JSON-RPC response for a completed ask_stream. + 26 Result *JSONRPCResponse + 27 // Error holds a terminal error to send before closing. + 28 Error *JSONRPCResponse + 29 } + 30 + 31 // StreamChunkMsg is a raw content chunk to be sent as a "stream" notification. + 32 type StreamChunkMsg struct { + 33 ID string + 34 SessionID string + 35 Type string + 36 Content string + 37 } + 38 + 39 // StreamHub manages per-session fan-out of live stream events. + 40 // Each session can have multiple subscribers; events published to the hub + 41 // are delivered to all of them in order. + 42 type StreamHub struct { + 43 mu sync.RWMutex + 44 subscribers map[string]map[string]chan HubEvent // sessionID -> subscriberID -> channel + 45 } + 46 + 47 // NewStreamHub creates a new StreamHub. + 48 func NewStreamHub() *StreamHub { + 49 return &StreamHub{ + 50 subscribers: make(map[string]map[string]chan HubEvent), + 51 } + 52 } + 53 + 54 // Subscribe registers a subscriber for a session's live stream. + 55 // Returns a channel that will receive all events for that session. + 56 // The caller must call Unsubscribe when done. + 57 func (h *StreamHub) Subscribe(sessionID, subscriberID string) <-chan HubEvent { + 58 ch := make(chan HubEvent, subscriberBufSize) + 59 + 60 h.mu.Lock() + 61 defer h.mu.Unlock() + 62 + 63 if h.subscribers[sessionID] == nil { + 64 h.subscribers[sessionID] = make(map[string]chan HubEvent) + 65 } + 66 h.subscribers[sessionID][subscriberID] = ch + 67 metrics.Default().Gauge(metrics.WSSubscribers).Inc() + 68 return ch + 69 } + 70 + 71 // Unsubscribe removes a subscriber. The subscriber's channel is closed. + 72 func (h *StreamHub) Unsubscribe(sessionID, subscriberID string) { + 73 h.mu.Lock() + 74 defer h.mu.Unlock() + 75 + 76 subs, ok := h.subscribers[sessionID] + 77 if !ok { + 78 return + 79 } + 80 if ch, exists := subs[subscriberID]; exists { + 81 delete(subs, subscriberID) + 82 close(ch) + 83 metrics.Default().Gauge(metrics.WSSubscribers).Dec() + 84 } + 85 if len(subs) == 0 { + 86 delete(h.subscribers, sessionID) + 87 } + 88 } + 89 + 90 // UnsubscribeAll removes all subscriptions for a subscriber across all sessions. + 91 func (h *StreamHub) UnsubscribeAll(subscriberID string) { + 92 h.mu.Lock() + 93 defer h.mu.Unlock() + 94 + 95 for sessionID, subs := range h.subscribers { + 96 if ch, exists := subs[subscriberID]; exists { + 97 delete(subs, subscriberID) + 98 close(ch) + 99 metrics.Default().Gauge(metrics.WSSubscribers).Dec() + 100 } + 101 if len(subs) == 0 { + 102 delete(h.subscribers, sessionID) + 103 } + 104 } + 105 } + 106 + 107 // Publish sends an event to all subscribers of a session. + 108 // Slow subscribers are dropped (their channel is closed and removed). + 109 func (h *StreamHub) Publish(sessionID string, evt HubEvent) { + 110 h.mu.Lock() + 111 defer h.mu.Unlock() + 112 + 113 subs := h.subscribers[sessionID] + 114 if len(subs) == 0 { + 115 return + 116 } + 117 + 118 // Identify slow subscribers whose buffers are full. + 119 var dropped []string + 120 for subID, ch := range subs { + 121 select { + 122 case ch <- evt: + 123 default: + 124 dropped = append(dropped, subID) + 125 } + 126 } + 127 + 128 // Remove and close channels for slow subscribers. + 129 for _, subID := range dropped { + 130 if ch, ok := subs[subID]; ok { + 131 log.Printf("[stream-hub] Dropping slow subscriber %s for session %s", subID, sessionID) + 132 delete(subs, subID) + 133 close(ch) + 134 metrics.Default().Counter(metrics.WSDroppedSubs).Inc() + 135 metrics.Default().Gauge(metrics.WSSubscribers).Dec() + 136 } + 137 } + 138 if len(subs) == 0 { + 139 delete(h.subscribers, sessionID) + 140 } + 141 } + 142 + 143 // SubscriberCount returns the number of active subscribers for a session. + 144 func (h *StreamHub) SubscriberCount(sessionID string) int { + 145 h.mu.RLock() + 146 defer h.mu.RUnlock() + 147 return len(h.subscribers[sessionID]) + 148 } + 149 + 150 // HasSubscribers returns true if the session has at least one subscriber. + 151 func (h *StreamHub) HasSubscribers(sessionID string) bool { + 152 h.mu.RLock() + 153 defer h.mu.RUnlock() + 154 return len(h.subscribers[sessionID]) > 0 + 155 } + +exec +/bin/zsh -lc "nl -ba /Users/chaoyuepan/ai/imclaw/internal/gateway/server.go | sed -n '730,1165p'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: + 730 } + 731 + 732 func (s *Server) handleRPCRequest(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 733 switch req.Method { + 734 case "ask": + 735 return s.handleAsk(connID, req) + 736 case "ask_stream": + 737 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &JSONRPCError{Code: -32601, Message: "ask_stream requires WebSocket connection"}} + 738 case "session.init": + 739 return s.handleSessionInit(connID, req) + 740 case "session.new": + 741 return s.handleSessionNew(connID, req) + 742 case "session.get": + 743 return s.handleSessionGet(connID, req) + 744 case "session.list": + 745 return s.handleSessionList(connID, req) + 746 case "session.update": + 747 return s.handleSessionUpdate(connID, req) + 748 case "session.delete": + 749 return s.handleSessionDelete(connID, req) + 750 case "session.rename": + 751 return s.handleSessionRename(connID, req) + 752 case "session.tag": + 753 return s.handleSessionTag(connID, req) + 754 case "session.untag": + 755 return s.handleSessionUntag(connID, req) + 756 case "session.archive": + 757 return s.handleSessionArchive(connID, req) + 758 case "session.unarchive": + 759 return s.handleSessionUnarchive(connID, req) + 760 case "session.subscribe": + 761 return s.handleSessionSubscribe(connID, req) + 762 case "session.export": + 763 return s.handleSessionExport(connID, req) + 764 case "session.import": + 765 return s.handleSessionImport(connID, req) + 766 case "agents.list": + 767 return s.handleAgentsList(connID, req) + 768 case "job.submit": + 769 return s.handleJobSubmit(connID, req) + 770 case "job.get": + 771 return s.handleJobGet(connID, req) + 772 case "job.list": + 773 return s.handleJobList(connID, req) + 774 case "job.cancel": + 775 return s.handleJobCancel(connID, req) + 776 case "job.delete": + 777 return s.handleJobDelete(connID, req) + 778 default: + 779 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &JSONRPCError{Code: -32601, Message: "Method not found"}} + 780 } + 781 } + 782 + 783 func (s *Server) handleAsk(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 784 askStart := time.Now() + 785 defer func() { + 786 metrics.Default().Latency(metrics.SessionPromptLatency).Since(askStart) + 787 }() + 788 + 789 params, ok := req.Params.(map[string]interface{}) + 790 if !ok { + 791 return invalidParams(req.ID) + 792 } + 793 + 794 content, _ := params["content"].(string) + 795 agentType, _ := params["agent"].(string) + 796 if content == "" { + 797 return missingParam(req.ID, "content") + 798 } + 799 + 800 sessionID := resolveSessionID(connID, getStringParam(params, "session_id")) + 801 if strings.HasPrefix(content, "/agent ") { + 802 newAgent := strings.TrimSpace(strings.TrimPrefix(content, "/agent ")) + 803 sess := s.sessionMgr.GetOrCreate(defaultSessionChannel, "", sessionID, newAgent) + 804 sess.AgentName = newAgent + 805 s.sessionMgr.Update(sess) + 806 s.broadcastSession(sess) + 807 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"agent": newAgent, "message": fmt.Sprintf("Switched to agent: %s", newAgent), "session_id": sess.ID}} + 808 } + 809 if content == "/new" { + 810 created := s.sessionMgr.Create(defaultSessionChannel, "", uuid.NewString(), agentType) + 811 s.broadcastSession(created) + 812 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"session_id": created.ID, "message": "New session created"}} + 813 } + 814 + 815 opts := parsePromptOptions(params) + 816 sess := s.prepareSession(sessionID, agentType) + 817 s.recordPrompt(sess.ID, req.ID, content) + 818 + 819 ag := s.agentMgr.GetOrCreate(sess.AgentName) + 820 agentSessionID, err := s.ensureAgentSession(sess, ag, req.ID) + 821 if err != nil { + 822 return s.rpcAgentError(sess.ID, req.ID, "Failed to create agent session", err) + 823 } + 824 + 825 response, err := ag.PromptWithOptions(context.Background(), agentSessionID, content, opts) + 826 if err != nil { + 827 return s.rpcAgentError(sess.ID, req.ID, "Agent error", err) + 828 } + 829 + 830 s.recordResult(sess.ID, req.ID, response) + 831 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"content": response, "session_id": sess.ID, "agent": ag.Type()}} + 832 } + 833 + 834 func (s *Server) handleSessionSubscribe(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 835 params, ok := req.Params.(map[string]interface{}) + 836 if !ok { + 837 return invalidParams(req.ID) + 838 } + 839 sessionID := getStringParam(params, "session_id") + 840 if sessionID == "" { + 841 return missingParam(req.ID, "session_id") + 842 } + 843 if connID == "" { + 844 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &JSONRPCError{Code: -32601, Message: "session.subscribe requires WebSocket connection"}} + 845 } + 846 // Verify the session exists. + 847 if _, ok := s.sessionMgr.Get(sessionChannelFromParams(params), sessionID); !ok { + 848 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &JSONRPCError{Code: -32602, Message: "Session not found"}} + 849 } + 850 + 851 // The actual subscription relay goroutine is spawned by the WS handler. + 852 // Store the pending subscription so the WS read loop picks it up. + 853 // For the JSON-RPC-over-HTTP path we return a confirmation. + 854 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"subscribed": true, "session_id": sessionID}} + 855 } + 856 + 857 // handleWSSubscribe handles a session.subscribe request over WebSocket. + 858 // It registers the connection as a subscriber to the session's live stream + 859 // and starts a goroutine to relay events from the hub to the connection. + 860 func (s *Server) handleWSSubscribe(conn *WSConnection, req *JSONRPCRequest) { + 861 params, ok := req.Params.(map[string]interface{}) + 862 if !ok { + 863 _ = conn.SendJSON(invalidParams(req.ID)) + 864 return + 865 } + 866 sessionID := getStringParam(params, "session_id") + 867 if sessionID == "" { + 868 _ = conn.SendJSON(missingParam(req.ID, "session_id")) + 869 return + 870 } + 871 if _, ok := s.sessionMgr.Get(sessionChannelFromParams(params), sessionID); !ok { + 872 _ = conn.SendJSON(&JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &JSONRPCError{Code: -32602, Message: "Session not found"}}) + 873 return + 874 } + 875 + 876 ch := s.hub.Subscribe(sessionID, conn.ID) + 877 + 878 _ = conn.SendJSON(&JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"subscribed": true, "session_id": sessionID}}) + 879 + 880 conn.streamWG.Add(1) + 881 go func() { + 882 defer conn.streamWG.Done() + 883 defer s.hub.Unsubscribe(sessionID, conn.ID) + 884 s.relaySubscription(conn, sessionID, ch) + 885 }() + 886 } + 887 + 888 // relaySubscription forwards events from a subscription channel to a WebSocket connection. + 889 func (s *Server) relaySubscription(conn *WSConnection, sessionID string, ch <-chan HubEvent) { + 890 for evt := range ch { + 891 switch { + 892 case evt.Result != nil: + 893 _ = conn.SendJSON(evt.Result) + 894 case evt.Error != nil: + 895 _ = conn.SendJSON(evt.Error) + 896 case evt.Event.Type != "": + 897 _ = conn.SendJSON(newEventNotification(sessionID, "", evt.Event)) + 898 case evt.Chunk.Type != "": + 899 _ = conn.SendJSON(JSONRPCRequest{ + 900 JSONRPC: "2.0", + 901 Method: "stream", + 902 Params: map[string]interface{}{ + 903 "id": evt.Chunk.ID, + 904 "session_id": evt.Chunk.SessionID, + 905 "type": evt.Chunk.Type, + 906 "content": evt.Chunk.Content, + 907 }, + 908 }) + 909 } + 910 } + 911 } + 912 + 913 func (s *Server) handleAskStream(conn *WSConnection, req *JSONRPCRequest) { + 914 streamStart := time.Now() + 915 defer func() { + 916 metrics.Default().Latency(metrics.SessionPromptLatency).Since(streamStart) + 917 }() + 918 + 919 params, ok := req.Params.(map[string]interface{}) + 920 if !ok { + 921 _ = conn.SendJSON(invalidParams(req.ID)) + 922 return + 923 } + 924 + 925 content, _ := params["content"].(string) + 926 agentType, _ := params["agent"].(string) + 927 if content == "" { + 928 _ = conn.SendJSON(missingParam(req.ID, "content")) + 929 return + 930 } + 931 + 932 sessionID := resolveSessionID(conn.ID, getStringParam(params, "session_id")) + 933 opts := parsePromptOptions(params) + 934 sess := s.prepareSession(sessionID, agentType) + 935 s.recordPrompt(sess.ID, req.ID, content) + 936 + 937 ag := s.agentMgr.GetOrCreate(sess.AgentName) + 938 agentSessionID, err := s.ensureAgentSession(sess, ag, req.ID) + 939 if err != nil { + 940 _ = conn.SendJSON(s.rpcAgentError(sess.ID, req.ID, "Failed to create agent session", err)) + 941 return + 942 } + 943 + 944 // Use a standalone context not tied to a single connection so that + 945 // the stream survives if the originating client disconnects. + 946 ctx, cancel := context.WithCancel(context.Background()) + 947 defer cancel() + 948 + 949 stream, err := ag.PromptStream(ctx, agentSessionID, content, opts) + 950 if err != nil { + 951 rpcErr := s.rpcAgentError(sess.ID, req.ID, "Failed to start stream", err) + 952 _ = conn.SendJSON(rpcErr) + 953 return + 954 } + 955 + 956 var fullContent strings.Builder + 957 var streamErr string + 958 var finalOutput string + 959 var sawFinalOutput bool + 960 parser := event.NewParser() + 961 sawNativeEvents := false + 962 sawErrorEvent := false + 963 toolStartTimes := make(map[string]time.Time) // tool name -> start time + 964 + 965 publishAndSend := func(evt agent.Event) { + 966 // Track tool call metrics + 967 switch evt.Type { + 968 case agent.TypeToolStart: + 969 toolStartTimes[evt.Name] = time.Now() + 970 metrics.Default().Counter(metrics.ToolCallCount).Inc() + 971 metrics.LogEvent("tool.start", sess.ID, req.ID, map[string]interface{}{ + 972 "tool_name": evt.Name, + 973 }) + 974 case agent.TypeToolEnd: + 975 if start, ok := toolStartTimes[evt.Name]; ok { + 976 metrics.Default().Latency(metrics.ToolCallDuration).Since(start) + 977 delete(toolStartTimes, evt.Name) + 978 } + 979 metrics.LogEvent("tool.end", sess.ID, req.ID, map[string]interface{}{ + 980 "tool_name": evt.Name, + 981 "input_size": len(evt.Input), + 982 "output_size": len(evt.Output), + 983 }) + 984 case agent.TypeError: + 985 if evt.Name != "" { + 986 metrics.Default().Counter(metrics.ToolCallErrors).Inc() + 987 } + 988 } + 989 + 990 s.recordEvent(sess.ID, req.ID, evt) + 991 // Fan-out to all hub subscribers. + 992 s.hub.Publish(sess.ID, HubEvent{Event: evt}) + 993 // Also send directly to the originating connection. + 994 if err := conn.SendJSON(newEventNotification(sess.ID, req.ID, evt)); err != nil { + 995 log.Printf("[gateway] WebSocket send failed: %v", err) + 996 } + 997 } + 998 + 999 for chunk := range stream { + 1000 applyStreamChunk(&fullContent, &streamErr, chunk) + 1001 if len(chunk.Events) > 0 { + 1002 sawNativeEvents = true + 1003 } + 1004 + 1005 for _, evt := range buildStructuredEvents(parser, chunk) { + 1006 if evt.Type == agent.TypeError { + 1007 sawErrorEvent = true + 1008 } + 1009 if evt.Type == agent.TypeOutputFinal { + 1010 finalOutput = evt.Content + 1011 sawFinalOutput = true + 1012 } + 1013 publishAndSend(evt) + 1014 } + 1015 + 1016 // Strip ANSI escape sequences from content before sending to WebSocket + 1017 cleanContent := event.StripANSI(chunk.Content) + 1018 chunkMsg := StreamChunkMsg{ID: req.ID, SessionID: sess.ID, Type: chunk.Type, Content: cleanContent} + 1019 s.hub.Publish(sess.ID, HubEvent{Chunk: chunkMsg}) + 1020 if err := conn.SendJSON(JSONRPCRequest{JSONRPC: "2.0", Method: "stream", Params: map[string]interface{}{"id": req.ID, "session_id": sess.ID, "type": chunk.Type, "content": cleanContent}}); err != nil { + 1021 log.Printf("[gateway] WebSocket send failed: %v", err) + 1022 } + 1023 } + 1024 + 1025 if !sawNativeEvents { + 1026 for _, evt := range flushStructuredEvents(parser, streamErr == "") { + 1027 if evt.Type == agent.TypeError { + 1028 sawErrorEvent = true + 1029 } + 1030 if evt.Type == agent.TypeOutputFinal { + 1031 finalOutput = evt.Content + 1032 sawFinalOutput = true + 1033 } + 1034 publishAndSend(evt) + 1035 } + 1036 } + 1037 + 1038 if streamErr != "" { + 1039 if !sawErrorEvent { + 1040 s.recordError(sess.ID, req.ID, streamErr) + 1041 } + 1042 errResp := JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &JSONRPCError{Code: -32603, Message: fmt.Sprintf("Agent error: %s", streamErr)}} + 1043 s.hub.Publish(sess.ID, HubEvent{Error: &errResp}) + 1044 _ = conn.SendJSON(errResp) + 1045 return + 1046 } + 1047 + 1048 // Prefer the protocol-level final output when available. Aggregating raw stream + 1049 // content can include thinking transcript text for native-event agents. + 1050 finalContent := filterTranscriptMarkers(event.StripANSI(fullContent.String())) + 1051 if sawFinalOutput { + 1052 finalContent = filterTranscriptMarkers(event.StripANSI(finalOutput)) + 1053 } + 1054 s.recordResult(sess.ID, req.ID, finalContent) + 1055 resultResp := JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"content": finalContent, "session_id": sess.ID, "agent": ag.Type()}} + 1056 s.hub.Publish(sess.ID, HubEvent{Result: &resultResp}) + 1057 _ = conn.SendJSON(resultResp) + 1058 } + 1059 + 1060 func (s *Server) prepareSession(sessionID, agentType string) *session.Session { + 1061 sess := s.sessionMgr.GetOrCreate(defaultSessionChannel, "", sessionID, agentType) + 1062 if agentType != "" && sess.AgentName != agentType { + 1063 sess.AgentName = agentType + 1064 s.sessionMgr.Update(sess) + 1065 sess, _ = s.sessionMgr.Get(defaultSessionChannel, sessionID) + 1066 } + 1067 s.broadcastSession(sess) + 1068 return sess + 1069 } + 1070 + 1071 func (s *Server) ensureAgentSession(sess *session.Session, ag agent.Agent, requestID string) (string, error) { + 1072 agentSessionHandle := sess.AgentSessionHandle + 1073 if agentSessionHandle != "" { + 1074 return agentSessionHandle, nil + 1075 } + 1076 + 1077 id, err := ag.EnsureSession(context.Background(), sess.ID) + 1078 if err != nil { + 1079 return "", err + 1080 } + 1081 // ACPX prompts are addressed by the stable session name we chose (`sess.ID`). + 1082 // Preserve the returned internal session ID separately for observability. + 1083 sess.AgentSession = id + 1084 sess.AgentSessionHandle = sess.ID + 1085 s.sessionMgr.Update(sess) + 1086 updated, _ := s.sessionMgr.Get(defaultSessionChannel, sess.ID) + 1087 s.broadcastSession(updated) + 1088 log.Printf("[gateway] Created agent session, name=%s, acpx_id=%s, request=%s", sess.ID, id, requestID) + 1089 return sess.AgentSessionHandle, nil + 1090 } + 1091 + 1092 func (s *Server) rpcAgentError(sessionID, requestID, prefix string, err error) *JSONRPCResponse { + 1093 message := fmt.Sprintf("%s: %v", prefix, err) + 1094 s.recordError(sessionID, requestID, message) + 1095 return &JSONRPCResponse{JSONRPC: "2.0", ID: requestID, Error: &JSONRPCError{Code: -32603, Message: message}} + 1096 } + 1097 + 1098 func (s *Server) recordPrompt(sessionID, requestID, prompt string) { + 1099 if sess, ok := s.sessionMgr.RecordPrompt(defaultSessionChannel, sessionID, requestID, prompt); ok { + 1100 s.broadcastSession(sess) + 1101 s.broadcastActivity(sess.ID, sess.Activity[len(sess.Activity)-1]) + 1102 metrics.Default().Counter(metrics.SessionCreated).Inc() + 1103 metrics.Default().Gauge(metrics.SessionActive).Set(int64(len(s.sessionMgr.Summaries()))) + 1104 metrics.LogEvent("session.prompt", sessionID, requestID, map[string]interface{}{ + 1105 "prompt_length": len(prompt), + 1106 }) + 1107 } + 1108 } + 1109 + 1110 func (s *Server) recordEvent(sessionID, requestID string, evt agent.Event) { + 1111 if sess, ok := s.sessionMgr.RecordEvent(defaultSessionChannel, sessionID, requestID, evt); ok { + 1112 s.broadcastSession(sess) + 1113 s.broadcastActivity(sess.ID, sess.Activity[len(sess.Activity)-1]) + 1114 } + 1115 } + 1116 + 1117 func (s *Server) recordResult(sessionID, requestID, content string) { + 1118 if sess, ok := s.sessionMgr.RecordResult(defaultSessionChannel, sessionID, requestID, content); ok { + 1119 s.broadcastSession(sess) + 1120 s.broadcastActivity(sess.ID, sess.Activity[len(sess.Activity)-1]) + 1121 metrics.Default().Counter(metrics.OutputSize).Add(int64(len(content))) + 1122 metrics.LogEvent("session.result", sessionID, requestID, map[string]interface{}{ + 1123 "output_length": len(content), + 1124 }) + 1125 } + 1126 } + 1127 + 1128 func (s *Server) recordError(sessionID, requestID, message string) { + 1129 if sess, ok := s.sessionMgr.RecordError(defaultSessionChannel, sessionID, requestID, message); ok { + 1130 s.broadcastSession(sess) + 1131 s.broadcastActivity(sess.ID, sess.Activity[len(sess.Activity)-1]) + 1132 metrics.Default().Counter(metrics.AgentExecFailures).Inc() + 1133 metrics.LogEvent("session.error", sessionID, requestID, map[string]interface{}{ + 1134 "error_message": metrics.Truncate(message, 200), + 1135 }) + 1136 } + 1137 } + 1138 + 1139 func (s *Server) broadcastSession(sess *session.Session) { + 1140 if sess == nil { + 1141 return + 1142 } + 1143 s.broadcastJSON(JSONRPCRequest{JSONRPC: "2.0", Method: "session.updated", Params: map[string]interface{}{"session": sess.Summary()}}) + 1144 } + 1145 + 1146 func (s *Server) broadcastActivity(sessionID string, activity session.Activity) { + 1147 s.broadcastJSON(JSONRPCRequest{JSONRPC: "2.0", Method: "session.activity", Params: map[string]interface{}{"session_id": sessionID, "activity": activity}}) + 1148 } + 1149 + 1150 func (s *Server) broadcastSessionDeleted(sessionID string) { + 1151 s.broadcastJSON(JSONRPCRequest{JSONRPC: "2.0", Method: "session.deleted", Params: map[string]interface{}{"session_id": sessionID}}) + 1152 } + 1153 + 1154 func (s *Server) broadcastJSON(v interface{}) { + 1155 s.connectionsMu.RLock() + 1156 conns := make([]*WSConnection, 0, len(s.connections)) + 1157 for _, conn := range s.connections { + 1158 conns = append(conns, conn) + 1159 } + 1160 s.connectionsMu.RUnlock() + 1161 + 1162 var failed []string + 1163 for _, conn := range conns { + 1164 if err := conn.SendJSON(v); err != nil { + 1165 failed = append(failed, conn.ID) + +exec +/bin/zsh -lc "rg -n \"metrics\\.Default\\(|metrics\\.LogEvent\\(|SessionCreated|SessionDeleted|SessionActive|WSSubscribers|WSDroppedSubs|ToolCallErrors|AgentExecFailures\" /Users/chaoyuepan/ai/imclaw/internal -g '*.go'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:67: metrics.Default().Gauge(metrics.WSSubscribers).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:83: metrics.Default().Gauge(metrics.WSSubscribers).Dec() +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:99: metrics.Default().Gauge(metrics.WSSubscribers).Dec() +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:134: metrics.Default().Counter(metrics.WSDroppedSubs).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:135: metrics.Default().Gauge(metrics.WSSubscribers).Dec() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:475: metrics.Default().Counter(metrics.JobSubmitted).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:563: metrics.Default().Counter(metrics.JobFailed).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:564: metrics.Default().Counter(metrics.AgentExecFailures).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:565: metrics.LogEvent("job.failed", sessionID, "", map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:572: metrics.Default().Latency(metrics.JobDuration).Since(execStart) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:573: metrics.Default().Counter(metrics.JobCompleted).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:574: metrics.Default().Counter(metrics.OutputSize).Add(int64(len(response))) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:603: metrics.Default().Counter(metrics.RequestTotal).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:604: metrics.Default().Latency(metrics.RequestLatency).Since(start) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:606: metrics.Default().Counter(metrics.RequestErrors).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:637: metrics.Default().Gauge(metrics.WSConnections).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:689: metrics.Default().Gauge(metrics.WSConnections).Dec() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:786: metrics.Default().Latency(metrics.SessionPromptLatency).Since(askStart) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:916: metrics.Default().Latency(metrics.SessionPromptLatency).Since(streamStart) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:970: metrics.Default().Counter(metrics.ToolCallCount).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:971: metrics.LogEvent("tool.start", sess.ID, req.ID, map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:976: metrics.Default().Latency(metrics.ToolCallDuration).Since(start) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:979: metrics.LogEvent("tool.end", sess.ID, req.ID, map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:986: metrics.Default().Counter(metrics.ToolCallErrors).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1102: metrics.Default().Counter(metrics.SessionCreated).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1103: metrics.Default().Gauge(metrics.SessionActive).Set(int64(len(s.sessionMgr.Summaries()))) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1104: metrics.LogEvent("session.prompt", sessionID, requestID, map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1121: metrics.Default().Counter(metrics.OutputSize).Add(int64(len(content))) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1122: metrics.LogEvent("session.result", sessionID, requestID, map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1132: metrics.Default().Counter(metrics.AgentExecFailures).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1133: metrics.LogEvent("session.error", sessionID, requestID, map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1150:func (s *Server) broadcastSessionDeleted(sessionID string) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1425: s.broadcastSessionDeleted(sessionID) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1426: metrics.Default().Counter(metrics.SessionDeleted).Inc() +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1427: metrics.Default().Gauge(metrics.SessionActive).Set(int64(len(s.sessionMgr.Summaries()))) +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics_test.go:287: SessionCreated, SessionDeleted, SessionActive, SessionPromptLatency, +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics_test.go:289: ToolCallCount, ToolCallDuration, ToolCallErrors, +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics_test.go:291: AgentExecDuration, AgentExecFailures, +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics_test.go:294: WSConnections, WSSubscribers, WSDroppedSubs, +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:453: metrics.Default().Latency(metrics.AgentExecDuration).Since(start) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:457: metrics.Default().Counter(metrics.PermissionDenials).Inc() +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:458: metrics.LogEvent("permission.denied", sessionID, "", map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:484: metrics.Default().Counter(metrics.PermissionDenials).Inc() +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:485: metrics.LogEvent("permission.denied", sessionID, "", map[string]interface{}{ +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:490: metrics.Default().Counter(metrics.AgentExecFailures).Inc() +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:501: metrics.Default().Latency(metrics.AgentExecDuration).Since(streamStart) +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:243: SessionCreated = "session.created" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:244: SessionDeleted = "session.deleted" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:245: SessionActive = "session.active_count" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:256: ToolCallErrors = "tool.call_errors" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:263: AgentExecFailures = "agent.exec_failures" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:276: WSSubscribers = "ws.active_subscribers" +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go:277: WSDroppedSubs = "ws.dropped_subscribers" + +codex +我发现 `server.go` 当前版本在 `recordError` 调用了 `metrics.Truncate`(和提交 diff 的局部 `truncate` 略有不同),我会先跑一次相关测试确认当前分支可编译,再继续找逻辑层面的缺陷。 +exec +/bin/zsh -lc "nl -ba /Users/chaoyuepan/ai/imclaw/internal/metrics/metrics.go | sed -n '1,380p'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: + 1 // Package metrics provides lightweight observability for IMClaw via structured logs. + 2 // + 3 // All metrics are emitted through Go's standard log package with a consistent + 4 // [metrics] prefix and structured key=value pairs, making them grep-friendly + 5 // and easy to feed into log aggregation pipelines. + 6 package metrics + 7 + 8 import ( + 9 "log" + 10 "sort" + 11 "sync" + 12 "sync/atomic" + 13 "time" + 14 ) + 15 + 16 // ---- Counters ---- + 17 + 18 // Counter is an atomically incremented metric counter. + 19 type Counter struct { + 20 name string + 21 count atomic.Int64 + 22 } + 23 + 24 // Inc increments the counter by 1. + 25 func (c *Counter) Inc() { + 26 v := c.count.Add(1) + 27 log.Printf("[metrics] counter %s=%d", c.name, v) + 28 } + 29 + 30 // Add increments the counter by n. Panics if n is negative; counters + 31 // must only increase. Use Gauge for values that can decrease. + 32 func (c *Counter) Add(n int64) { + 33 if n < 0 { + 34 log.Printf("[metrics] counter %s: Add called with negative value %d, ignoring", c.name, n) + 35 return + 36 } + 37 v := c.count.Add(n) + 38 log.Printf("[metrics] counter %s=%d delta=%d", c.name, v, n) + 39 } + 40 + 41 // Value returns the current counter value. + 42 func (c *Counter) Value() int64 { + 43 return c.count.Load() + 44 } + 45 + 46 // ---- Latency Tracker ---- + 47 + 48 // maxSamples caps the number of latency samples retained per tracker. + 49 // Using a bounded ring buffer prevents unbounded memory growth in + 50 // long-running processes. + 51 const maxSamples = 1000 + 52 + 53 // LatencyTracker measures duration distributions for named operations. + 54 // It retains at most maxSamples recent observations in a ring buffer. + 55 type LatencyTracker struct { + 56 name string + 57 mu sync.Mutex + 58 samples [maxSamples]time.Duration + 59 head int // next write position + 60 count int // total observations written (capped at maxSamples) + 61 } + 62 + 63 // Observe records a duration and emits a structured log line. + 64 func (lt *LatencyTracker) Observe(d time.Duration) { + 65 lt.mu.Lock() + 66 lt.samples[lt.head] = d + 67 lt.head = (lt.head + 1) % maxSamples + 68 if lt.count < maxSamples { + 69 lt.count++ + 70 } + 71 lt.mu.Unlock() + 72 + 73 log.Printf("[metrics] latency %s duration_ms=%.2f", lt.name, float64(d)/float64(time.Millisecond)) + 74 } + 75 + 76 // Since returns a duration from the given start time. It is a convenience + 77 // wrapper intended for one-line usage: defer tracker.Since(time.Now()) + 78 func (lt *LatencyTracker) Since(start time.Time) { + 79 lt.Observe(time.Since(start)) + 80 } + 81 + 82 // Summary returns aggregate statistics (count, min, max, avg, p50, p95, p99). + 83 // Returns zero values if no samples have been recorded. + 84 func (lt *LatencyTracker) Summary() LatencySummary { + 85 lt.mu.Lock() + 86 n := lt.count + 87 samples := make([]time.Duration, n) + 88 // Ring buffer: if count < maxSamples, data is in samples[0:count]. + 89 // Otherwise, head marks the oldest entry. + 90 if n < maxSamples { + 91 copy(samples, lt.samples[:n]) + 92 } else { + 93 copy(samples, lt.samples[lt.head:]) + 94 copy(samples[maxSamples-lt.head:], lt.samples[:lt.head]) + 95 } + 96 lt.mu.Unlock() + 97 + 98 return computeSummary(lt.name, samples) + 99 } + 100 + 101 // LatencySummary holds aggregate latency statistics. + 102 type LatencySummary struct { + 103 Name string + 104 Count int + 105 Min time.Duration + 106 Max time.Duration + 107 Avg time.Duration + 108 P50 time.Duration + 109 P95 time.Duration + 110 P99 time.Duration + 111 } + 112 + 113 // ---- Gauge ---- + 114 + 115 // Gauge tracks a point-in-time integer value. + 116 type Gauge struct { + 117 name string + 118 value atomic.Int64 + 119 } + 120 + 121 // Set updates the gauge value. + 122 func (g *Gauge) Set(v int64) { + 123 g.value.Store(v) + 124 log.Printf("[metrics] gauge %s=%d", g.name, v) + 125 } + 126 + 127 // Inc increments the gauge by 1. + 128 func (g *Gauge) Inc() int64 { + 129 v := g.value.Add(1) + 130 log.Printf("[metrics] gauge %s=%d", g.name, v) + 131 return v + 132 } + 133 + 134 // Dec decrements the gauge by 1. + 135 func (g *Gauge) Dec() int64 { + 136 v := g.value.Add(-1) + 137 log.Printf("[metrics] gauge %s=%d", g.name, v) + 138 return v + 139 } + 140 + 141 // Value returns the current gauge value. + 142 func (g *Gauge) Value() int64 { + 143 return g.value.Load() + 144 } + 145 + 146 // ---- Registry ---- + 147 + 148 // Registry groups named metrics for a subsystem. + 149 type Registry struct { + 150 mu sync.Mutex + 151 counters map[string]*Counter + 152 latency map[string]*LatencyTracker + 153 gauges map[string]*Gauge + 154 } + 155 + 156 // NewRegistry creates a new metrics registry. + 157 func NewRegistry() *Registry { + 158 return &Registry{ + 159 counters: make(map[string]*Counter), + 160 latency: make(map[string]*LatencyTracker), + 161 gauges: make(map[string]*Gauge), + 162 } + 163 } + 164 + 165 // Counter returns (or creates) a counter by name. + 166 func (r *Registry) Counter(name string) *Counter { + 167 r.mu.Lock() + 168 defer r.mu.Unlock() + 169 if c, ok := r.counters[name]; ok { + 170 return c + 171 } + 172 c := &Counter{name: name} + 173 r.counters[name] = c + 174 return c + 175 } + 176 + 177 // Latency returns (or creates) a latency tracker by name. + 178 func (r *Registry) Latency(name string) *LatencyTracker { + 179 r.mu.Lock() + 180 defer r.mu.Unlock() + 181 if lt, ok := r.latency[name]; ok { + 182 return lt + 183 } + 184 lt := &LatencyTracker{name: name} + 185 r.latency[name] = lt + 186 return lt + 187 } + 188 + 189 // Gauge returns (or creates) a gauge by name. + 190 func (r *Registry) Gauge(name string) *Gauge { + 191 r.mu.Lock() + 192 defer r.mu.Unlock() + 193 if g, ok := r.gauges[name]; ok { + 194 return g + 195 } + 196 g := &Gauge{name: name} + 197 r.gauges[name] = g + 198 return g + 199 } + 200 + 201 // Snapshot returns a point-in-time snapshot of all metrics. + 202 func (r *Registry) Snapshot() Snapshot { + 203 r.mu.Lock() + 204 defer r.mu.Unlock() + 205 + 206 snap := Snapshot{ + 207 Counters: make(map[string]int64, len(r.counters)), + 208 Gauges: make(map[string]int64, len(r.gauges)), + 209 Latency: make(map[string]LatencySummary, len(r.latency)), + 210 } + 211 for name, c := range r.counters { + 212 snap.Counters[name] = c.Value() + 213 } + 214 for name, g := range r.gauges { + 215 snap.Gauges[name] = g.Value() + 216 } + 217 for name, lt := range r.latency { + 218 snap.Latency[name] = lt.Summary() + 219 } + 220 return snap + 221 } + 222 + 223 // Snapshot is a point-in-time view of all registry metrics. + 224 type Snapshot struct { + 225 Counters map[string]int64 + 226 Gauges map[string]int64 + 227 Latency map[string]LatencySummary + 228 } + 229 + 230 // ---- Global default registry ---- + 231 + 232 var defaultRegistry = NewRegistry() + 233 + 234 // Default returns the global default metrics registry. + 235 func Default() *Registry { + 236 return defaultRegistry + 237 } + 238 + 239 // Predefined metric names following dashboard-friendly conventions. + 240 // Naming: . + 241 const ( + 242 // Session metrics + 243 SessionCreated = "session.created" + 244 SessionDeleted = "session.deleted" + 245 SessionActive = "session.active_count" + 246 SessionPromptLatency = "session.prompt_latency" + 247 + 248 // Request metrics + 249 RequestTotal = "request.total" + 250 RequestLatency = "request.latency" + 251 RequestErrors = "request.errors" + 252 + 253 // Tool metrics + 254 ToolCallCount = "tool.call_count" + 255 ToolCallDuration = "tool.call_duration" + 256 ToolCallErrors = "tool.call_errors" + 257 + 258 // Permission metrics + 259 PermissionDenials = "permission.denials" + 260 + 261 // Agent metrics + 262 AgentExecDuration = "agent.exec_duration" + 263 AgentExecFailures = "agent.exec_failures" + 264 + 265 // Output metrics + 266 OutputSize = "output.size_bytes" + 267 + 268 // Job metrics + 269 JobSubmitted = "job.submitted" + 270 JobCompleted = "job.completed" + 271 JobFailed = "job.failed" + 272 JobDuration = "job.duration" + 273 + 274 // Connection metrics + 275 WSConnections = "ws.active_connections" + 276 WSSubscribers = "ws.active_subscribers" + 277 WSDroppedSubs = "ws.dropped_subscribers" + 278 ) + 279 + 280 // ---- Event logging helpers ---- + 281 + 282 // LogEvent emits a structured event log line for key operational events. + 283 func LogEvent(event, sessionID, requestID string, extra map[string]interface{}) { + 284 pairs := make([]interface{}, 0, 2+len(extra)*2) + 285 pairs = append(pairs, "event", event) + 286 if sessionID != "" { + 287 pairs = append(pairs, "session_id", sessionID) + 288 } + 289 if requestID != "" { + 290 pairs = append(pairs, "request_id", requestID) + 291 } + 292 for k, v := range extra { + 293 pairs = append(pairs, k, v) + 294 } + 295 log.Printf("[metrics] event "+repeatFormat(len(pairs)/2), pairs...) + 296 } + 297 + 298 func repeatFormat(n int) string { + 299 const pair = " %s=%v" + 300 result := "" + 301 for i := 0; i < n; i++ { + 302 result += pair + 303 } + 304 return result + 305 } + 306 + 307 // ---- Internal helpers ---- + 308 + 309 func computeSummary(name string, samples []time.Duration) LatencySummary { + 310 s := LatencySummary{ + 311 Name: name, + 312 Count: len(samples), + 313 } + 314 if len(samples) == 0 { + 315 return s + 316 } + 317 + 318 // Sort a copy to compute percentiles + 319 sorted := make([]time.Duration, len(samples)) + 320 copy(sorted, samples) + 321 sort.Sort(sortableDurations(sorted)) + 322 + 323 n := len(sorted) + 324 s.Min = sorted[0] + 325 s.Max = sorted[n-1] + 326 + 327 var total time.Duration + 328 for _, d := range sorted { + 329 total += d + 330 } + 331 s.Avg = total / time.Duration(n) + 332 s.P50 = sorted[n*50/100] + 333 s.P95 = sorted[n*95/100] + 334 s.P99 = sorted[n*99/100] + 335 + 336 return s + 337 } + 338 + 339 type sortableDurations []time.Duration + 340 + 341 func (d sortableDurations) Len() int { return len(d) } + 342 func (d sortableDurations) Less(i, j int) bool { return d[i] < d[j] } + 343 func (d sortableDurations) Swap(i, j int) { d[i], d[j] = d[j], d[i] } + 344 + 345 // Truncate shortens s to at most maxLen bytes, appending "..." if truncated. + 346 func Truncate(s string, maxLen int) string { + 347 if len(s) <= maxLen { + 348 return s + 349 } + 350 return s[:maxLen] + "..." + 351 } + +exec +/bin/zsh -lc 'cd /Users/chaoyuepan/ai/imclaw && go test ./internal/metrics ./internal/gateway ./internal/agent' in /Users/chaoyuepan/ai/imclaw +2026-04-18T08:12:09.276864Z ERROR codex_core::tools::router: error=exec_command failed for `/bin/zsh -lc 'cd /Users/chaoyuepan/ai/imclaw && go test ./internal/metrics ./internal/gateway ./internal/agent'`: SandboxDenied { message: "ok \tgithub.com/smallnest/imclaw/internal/metrics\t0.721s\n2026/04/18 16:12:08 [gateway] Created agent session, name=sess-ensure, acpx_id=acpx-123, request=req-1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=0\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=0\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=3\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=3\n2026/04/18 16:12:08 [stream-hub] Dropping slow subscriber slow-sub for session sess-1\n2026/04/18 16:12:08 [metrics] counter ws.dropped_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=1\n--- FAIL: TestMultipleClientsSameSession (0.00s)\npanic: httptest: failed to listen on a port: listen tcp6 [::1]:0: bind: operation not permitted [recovered, repanicked]\n\ngoroutine 68 [running]:\ntesting.tRunner.func1.2({0x1006d78a0, 0x3bce68212160})\n\t/usr/local/go/src/testing/testing.go:1974 +0x1a0\ntesting.tRunner.func1()\n\t/usr/local/go/src/testing/testing.go:1977 +0x318\npanic({0x1006d78a0?, 0x3bce68212160?})\n\t/usr/local/go/src/runtime/panic.go:860 +0x12c\nnet/http/httptest.newLocalListener()\n\t/usr/local/go/src/net/http/httptest/server.go:72 +0xd4\nnet/http/httptest.NewUnstartedServer(...)\n\t/usr/local/go/src/net/http/httptest/server.go:120\nnet/http/httptest.NewServer({0x10076b120, 0x3bce68212120})\n\t/usr/local/go/src/net/http/httptest/server.go:107 +0x28\ngithub.com/smallnest/imclaw/internal/gateway.TestMultipleClientsSameSession(0x3bce682286c8)\n\t/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:227 +0x2f8\ntesting.tRunner(0x3bce682286c8, 0x100766d18)\n\t/usr/local/go/src/testing/testing.go:2036 +0xc4\ncreated by testing.(*T).Run in goroutine 1\n\t/usr/local/go/src/testing/testing.go:2101 +0x3a8\nFAIL\tgithub.com/smallnest/imclaw/internal/gateway\t0.967s\nok \tgithub.com/smallnest/imclaw/internal/agent\t1.192s\nFAIL\n", output: ExecToolCallOutput { exit_code: 1, stdout: StreamOutput { text: "", truncated_after_lines: None }, stderr: StreamOutput { text: "ok \tgithub.com/smallnest/imclaw/internal/metrics\t0.721s\n2026/04/18 16:12:08 [gateway] Created agent session, name=sess-ensure, acpx_id=acpx-123, request=req-1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=0\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=0\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=3\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=3\n2026/04/18 16:12:08 [stream-hub] Dropping slow subscriber slow-sub for session sess-1\n2026/04/18 16:12:08 [metrics] counter ws.dropped_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=1\n--- FAIL: TestMultipleClientsSameSession (0.00s)\npanic: httptest: failed to listen on a port: listen tcp6 [::1]:0: bind: operation not permitted [recovered, repanicked]\n\ngoroutine 68 [running]:\ntesting.tRunner.func1.2({0x1006d78a0, 0x3bce68212160})\n\t/usr/local/go/src/testing/testing.go:1974 +0x1a0\ntesting.tRunner.func1()\n\t/usr/local/go/src/testing/testing.go:1977 +0x318\npanic({0x1006d78a0?, 0x3bce68212160?})\n\t/usr/local/go/src/runtime/panic.go:860 +0x12c\nnet/http/httptest.newLocalListener()\n\t/usr/local/go/src/net/http/httptest/server.go:72 +0xd4\nnet/http/httptest.NewUnstartedServer(...)\n\t/usr/local/go/src/net/http/httptest/server.go:120\nnet/http/httptest.NewServer({0x10076b120, 0x3bce68212120})\n\t/usr/local/go/src/net/http/httptest/server.go:107 +0x28\ngithub.com/smallnest/imclaw/internal/gateway.TestMultipleClientsSameSession(0x3bce682286c8)\n\t/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:227 +0x2f8\ntesting.tRunner(0x3bce682286c8, 0x100766d18)\n\t/usr/local/go/src/testing/testing.go:2036 +0xc4\ncreated by testing.(*T).Run in goroutine 1\n\t/usr/local/go/src/testing/testing.go:2101 +0x3a8\nFAIL\tgithub.com/smallnest/imclaw/internal/gateway\t0.967s\nok \tgithub.com/smallnest/imclaw/internal/agent\t1.192s\nFAIL\n", truncated_after_lines: None }, aggregated_output: StreamOutput { text: "ok \tgithub.com/smallnest/imclaw/internal/metrics\t0.721s\n2026/04/18 16:12:08 [gateway] Created agent session, name=sess-ensure, acpx_id=acpx-123, request=req-1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=0\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=0\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=3\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=3\n2026/04/18 16:12:08 [stream-hub] Dropping slow subscriber slow-sub for session sess-1\n2026/04/18 16:12:08 [metrics] counter ws.dropped_subscribers=1\n2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=1\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=2\n2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=1\n--- FAIL: TestMultipleClientsSameSession (0.00s)\npanic: httptest: failed to listen on a port: listen tcp6 [::1]:0: bind: operation not permitted [recovered, repanicked]\n\ngoroutine 68 [running]:\ntesting.tRunner.func1.2({0x1006d78a0, 0x3bce68212160})\n\t/usr/local/go/src/testing/testing.go:1974 +0x1a0\ntesting.tRunner.func1()\n\t/usr/local/go/src/testing/testing.go:1977 +0x318\npanic({0x1006d78a0?, 0x3bce68212160?})\n\t/usr/local/go/src/runtime/panic.go:860 +0x12c\nnet/http/httptest.newLocalListener()\n\t/usr/local/go/src/net/http/httptest/server.go:72 +0xd4\nnet/http/httptest.NewUnstartedServer(...)\n\t/usr/local/go/src/net/http/httptest/server.go:120\nnet/http/httptest.NewServer({0x10076b120, 0x3bce68212120})\n\t/usr/local/go/src/net/http/httptest/server.go:107 +0x28\ngithub.com/smallnest/imclaw/internal/gateway.TestMultipleClientsSameSession(0x3bce682286c8)\n\t/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:227 +0x2f8\ntesting.tRunner(0x3bce682286c8, 0x100766d18)\n\t/usr/local/go/src/testing/testing.go:2036 +0xc4\ncreated by testing.(*T).Run in goroutine 1\n\t/usr/local/go/src/testing/testing.go:2101 +0x3a8\nFAIL\tgithub.com/smallnest/imclaw/internal/gateway\t0.967s\nok \tgithub.com/smallnest/imclaw/internal/agent\t1.192s\nFAIL\n", truncated_after_lines: None }, duration: 0ns, timed_out: false } } + exited 1 in 1752ms: +ok github.com/smallnest/imclaw/internal/metrics 0.721s +2026/04/18 16:12:08 [gateway] Created agent session, name=sess-ensure, acpx_id=acpx-123, request=req-1 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=0 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=0 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=3 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=1 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=3 +2026/04/18 16:12:08 [stream-hub] Dropping slow subscriber slow-sub for session sess-1 +2026/04/18 16:12:08 [metrics] counter ws.dropped_subscribers=1 +2026/04/18 16:12:08 [metrics] gauge ws.active_subscribers=2 +2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=1 +2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=2 +2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=1 +2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=2 +2026/04/18 16:12:09 [metrics] gauge ws.active_subscribers=1 +--- FAIL: TestMultipleClientsSameSession (0.00s) +panic: httptest: failed to listen on a port: listen tcp6 [::1]:0: bind: operation not permitted [recovered, repanicked] + +goroutine 68 [running]: +testing.tRunner.func1.2({0x1006d78a0, 0x3bce68212160}) + /usr/local/go/src/testing/testing.go:1974 +0x1a0 +testing.tRunner.func1() + /usr/local/go/src/testing/testing.go:1977 +0x318 +panic({0x1006d78a0?, 0x3bce68212160?}) + /usr/local/go/src/runtime/panic.go:860 +0x12c +net/http/httptest.newLocalListener() + /usr/local/go/src/net/http/httptest/server.go:72 +0xd4 +net/http/httptest.NewUnstartedServer(...) + /usr/local/go/src/net/http/httptest/server.go:120 +net/http/httptest.NewServer({0x10076b120, 0x3bce68212120}) + /usr/local/go/src/net/http/httptest/server.go:107 +0x28 +github.com/smallnest/imclaw/internal/gateway.TestMultipleClientsSameSession(0x3bce682286c8) + /Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:227 +0x2f8 +testing.tRunner(0x3bce682286c8, 0x100766d18) + /usr/local/go/src/testing/testing.go:2036 +0xc4 +created by testing.(*T).Run in goroutine 1 + /usr/local/go/src/testing/testing.go:2101 +0x3a8 +FAIL github.com/smallnest/imclaw/internal/gateway 0.967s +ok github.com/smallnest/imclaw/internal/agent 1.192s +FAIL + +exec +/bin/zsh -lc "nl -ba /Users/chaoyuepan/ai/imclaw/internal/agent/agent.go | sed -n '420,620p'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: + 420 } + 421 + 422 // PromptWithOptions sends a prompt with options + 423 func (a *ACPXAgent) PromptWithOptions(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (string, error) { + 424 if opts == nil { + 425 opts = &PromptOptions{} + 426 } + 427 + 428 return a.doPrompt(ctx, sessionID, prompt, opts) + 429 } + 430 + 431 // PromptStream sends a prompt and streams the response + 432 func (a *ACPXAgent) PromptStream(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (<-chan StreamChunk, error) { + 433 if opts == nil { + 434 opts = &PromptOptions{} + 435 } + 436 + 437 return a.doPromptStream(ctx, sessionID, prompt, opts) + 438 } + 439 + 440 func (a *ACPXAgent) doPrompt(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (string, error) { + 441 policy, err := resolvePromptPolicy(opts) + 442 if err != nil { + 443 return "", err + 444 } + 445 + 446 args, timeout, format := buildPromptArgs(a.agentType, sessionID, prompt, opts, policy, false) + 447 + 448 log.Printf("[acpx] Sending prompt to session %s (%s, format=%s)", sessionID, policy.Summary(), format) + 449 log.Printf("[acpx] Prompt: %s", metrics.Truncate(prompt, 200)) + 450 + 451 start := time.Now() + 452 response, err := a.runCommand(ctx, timeout, args...) + 453 metrics.Default().Latency(metrics.AgentExecDuration).Since(start) + 454 if err != nil { + 455 annotated := annotatePermissionError(err.Error(), policy) + 456 if isPermissionError(err.Error()) { + 457 metrics.Default().Counter(metrics.PermissionDenials).Inc() + 458 metrics.LogEvent("permission.denied", sessionID, "", map[string]interface{}{ + 459 "agent": a.agentType, + 460 "policy": policy.Summary(), + 461 }) + 462 } + 463 return "", fmt.Errorf("%s", annotated) + 464 } + 465 return response, nil + 466 } + 467 + 468 // doPromptStream executes the prompt and streams the output + 469 func (a *ACPXAgent) doPromptStream(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (<-chan StreamChunk, error) { + 470 policy, err := resolvePromptPolicy(opts) + 471 if err != nil { + 472 return nil, err + 473 } + 474 + 475 args, timeout, _ := buildPromptArgs(a.agentType, sessionID, prompt, opts, policy, true) + 476 + 477 log.Printf("[acpx] Streaming prompt to session %s (%s)", sessionID, policy.Summary()) + 478 log.Printf("[acpx] Prompt: %s", metrics.Truncate(prompt, 200)) + 479 + 480 streamStart := time.Now() + 481 ch, err := a.runCommandStream(ctx, timeout, policy, args...) + 482 if err != nil { + 483 if isPermissionError(err.Error()) { + 484 metrics.Default().Counter(metrics.PermissionDenials).Inc() + 485 metrics.LogEvent("permission.denied", sessionID, "", map[string]interface{}{ + 486 "agent": a.agentType, + 487 "policy": policy.Summary(), + 488 }) + 489 } + 490 metrics.Default().Counter(metrics.AgentExecFailures).Inc() + 491 return nil, err + 492 } + 493 + 494 // Wrap the channel to track duration when stream completes + 495 wrappedCh := make(chan StreamChunk, 200) + 496 go func() { + 497 defer close(wrappedCh) + 498 for chunk := range ch { + 499 wrappedCh <- chunk + 500 } + 501 metrics.Default().Latency(metrics.AgentExecDuration).Since(streamStart) + 502 }() + 503 + 504 return wrappedCh, nil + 505 } + 506 + 507 func resolvePromptPolicy(opts *PromptOptions) (*permission.ResolvedPolicy, error) { + 508 if opts == nil { + 509 opts = &PromptOptions{} + 510 } + 511 return permission.Resolve(permission.Policy{ + 512 PresetName: opts.PermissionPreset, + 513 Permissions: opts.Permissions, + 514 AllowedTools: opts.AllowedTools, + 515 DeniedTools: opts.DeniedTools, + 516 AuthPolicy: opts.AuthPolicy, + 517 NonInteractivePerms: opts.NonInteractivePerms, + 518 }) + 519 } + 520 + 521 func buildPromptArgs(agentType, sessionID, prompt string, opts *PromptOptions, policy *permission.ResolvedPolicy, streaming bool) ([]string, int, string) { + 522 args := []string{} + 523 + 524 if opts.Cwd != "" { + 525 args = append(args, "--cwd", opts.Cwd) + 526 } + 527 if policy.AuthPolicy != "" { + 528 args = append(args, "--auth-policy", policy.AuthPolicy) + 529 } + 530 if policy.Permissions != "" { + 531 switch policy.Permissions { + 532 case "approve-all": + 533 args = append(args, "--approve-all") + 534 case "approve-reads": + 535 args = append(args, "--approve-reads") + 536 case "deny-all": + 537 args = append(args, "--deny-all") + 538 } + 539 } + 540 if policy.NonInteractivePerms != "" { + 541 args = append(args, "--non-interactive-permissions", policy.NonInteractivePerms) + 542 } + 543 + 544 format := opts.Format + 545 if format == "" { + 546 format = "text" + 547 } + 548 args = append(args, "--format", format) + 549 + 550 if opts.SuppressReads { + 551 args = append(args, "--suppress-reads") + 552 } + 553 if opts.Model != "" { + 554 args = append(args, "--model", opts.Model) + 555 } + 556 if allowed := policy.AllowedToolsCSV(); allowed != "" { + 557 args = append(args, "--allowed-tools", allowed) + 558 } + 559 if opts.MaxTurns > 0 { + 560 args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns)) + 561 } + 562 if opts.PromptRetries > 0 { + 563 args = append(args, "--prompt-retries", fmt.Sprintf("%d", opts.PromptRetries)) + 564 } + 565 + 566 timeout := 300 + 567 if opts.Timeout > 0 { + 568 timeout = opts.Timeout + 569 args = append(args, "--timeout", fmt.Sprintf("%d", opts.Timeout)) + 570 } + 571 if opts.TTL > 0 { + 572 args = append(args, "--ttl", fmt.Sprintf("%d", opts.TTL)) + 573 } + 574 + 575 args = append(args, agentType, "-s", sessionID, prompt) + 576 return args, timeout, format + 577 } + 578 + 579 func annotatePermissionError(message string, policy *permission.ResolvedPolicy) string { + 580 if policy == nil { + 581 return message + 582 } + 583 if isPermissionError(message) { + 584 return fmt.Sprintf("permission policy denied request (%s): %s", policy.Summary(), message) + 585 } + 586 return message + 587 } + 588 + 589 func isPermissionError(message string) bool { + 590 lower := strings.ToLower(message) + 591 return strings.Contains(lower, "permission") || strings.Contains(lower, "exit status 5") || strings.Contains(lower, "refused") + 592 } + 593 + 594 // runCommandStream executes command and streams the output + 595 func (a *ACPXAgent) runCommandStream(ctx context.Context, timeout int, policy *permission.ResolvedPolicy, args ...string) (<-chan StreamChunk, error) { + 596 if timeout == 0 { + 597 timeout = 300 + 598 } + 599 + 600 ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + 601 + 602 // Log the command being executed + 603 fullCmd := fmt.Sprintf("%s %s", a.command, strings.Join(args, " ")) + 604 log.Printf("[acpx] Executing (stream): %s", fullCmd) + 605 + 606 cmd := exec.CommandContext(ctx, a.command, args...) + 607 + 608 // acpx only emits true incremental output when attached to a PTY. + 609 ptmx, err := pty.Start(cmd) + 610 if err != nil { + 611 cancel() + 612 return nil, fmt.Errorf("failed to start PTY command: %w", err) + 613 } + 614 + 615 // Create output channel with larger buffer + 616 outputCh := make(chan StreamChunk, 200) + 617 + 618 // Goroutine to read and stream output + 619 go func() { + 620 defer close(outputCh) + +codex +我已经定位到几个可疑点:`session.created` 的语义、`ask_stream` 失败路径的失败计数重复、以及工具耗时按 `tool name` 关联可能失真。接下来我会用 `master...HEAD` 的差异和现有测试文件核对这些点是否被测试覆盖。 +exec +/bin/zsh -lc "git -C /Users/chaoyuepan/ai/imclaw diff master...HEAD -- internal/gateway/server_test.go internal/gateway/stream_hub_test.go internal/agent/agent_test.go internal/metrics/metrics_test.go | sed -n '1,260p'" in /Users/chaoyuepan/ai/imclaw +exec +/bin/zsh -lc 'git -C /Users/chaoyuepan/ai/imclaw diff --name-only master...HEAD' in /Users/chaoyuepan/ai/imclaw +exec +/bin/zsh -lc 'rg -n "SessionCreated|session.created|ToolCallErrors|AgentExecFailures|ask_stream|metrics" /Users/chaoyuepan/ai/imclaw/internal/gateway/*test.go /Users/chaoyuepan/ai/imclaw/internal/agent/*test.go /Users/chaoyuepan/ai/imclaw/internal/metrics/*test.go' in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics_test.go:1:package metrics +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics_test.go:287: SessionCreated, SessionDeleted, SessionActive, SessionPromptLatency, +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics_test.go:289: ToolCallCount, ToolCallDuration, ToolCallErrors, +/Users/chaoyuepan/ai/imclaw/internal/metrics/metrics_test.go:291: AgentExecDuration, AgentExecFailures, + + succeeded in 0ms: +diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go +new file mode 100644 +index 0000000..db84e8c +--- /dev/null ++++ b/internal/metrics/metrics_test.go +@@ -0,0 +1,214 @@ ++package metrics ++ ++import ( ++ "testing" ++ "time" ++) ++ ++func TestCounterInc(t *testing.T) { ++ r := NewRegistry() ++ c := r.Counter("test.counter") ++ ++ if v := c.Value(); v != 0 { ++ t.Fatalf("expected initial value 0, got %d", v) ++ } ++ c.Inc() ++ if v := c.Value(); v != 1 { ++ t.Fatalf("expected 1 after Inc, got %d", v) ++ } ++} ++ ++func TestCounterAdd(t *testing.T) { ++ r := NewRegistry() ++ c := r.Counter("test.counter_add") ++ ++ c.Add(5) ++ if v := c.Value(); v != 5 { ++ t.Fatalf("expected 5 after Add(5), got %d", v) ++ } ++ c.Add(3) ++ if v := c.Value(); v != 8 { ++ t.Fatalf("expected 8 after Add(3), got %d", v) ++ } ++} ++ ++func TestCounterRegistryReturnsSame(t *testing.T) { ++ r := NewRegistry() ++ c1 := r.Counter("same_name") ++ c2 := r.Counter("same_name") ++ ++ c1.Inc() ++ if v := c2.Value(); v != 1 { ++ t.Fatalf("expected shared counter to be 1, got %d", v) ++ } ++} ++ ++func TestGaugeSet(t *testing.T) { ++ r := NewRegistry() ++ g := r.Gauge("test.gauge") ++ ++ g.Set(42) ++ if v := g.Value(); v != 42 { ++ t.Fatalf("expected 42 after Set, got %d", v) ++ } ++ g.Set(100) ++ if v := g.Value(); v != 100 { ++ t.Fatalf("expected 100 after Set, got %d", v) ++ } ++} ++ ++func TestGaugeIncDec(t *testing.T) { ++ r := NewRegistry() ++ g := r.Gauge("test.gauge_incdec") ++ ++ g.Inc() ++ g.Inc() ++ if v := g.Value(); v != 2 { ++ t.Fatalf("expected 2 after two Inc, got %d", v) ++ } ++ g.Dec() ++ if v := g.Value(); v != 1 { ++ t.Fatalf("expected 1 after Dec, got %d", v) ++ } ++} ++ ++func TestLatencyTrackerObserve(t *testing.T) { ++ r := NewRegistry() ++ lt := r.Latency("test.latency") ++ ++ lt.Observe(100 * time.Millisecond) ++ lt.Observe(200 * time.Millisecond) ++ lt.Observe(300 * time.Millisecond) ++ ++ s := lt.Summary() ++ if s.Count != 3 { ++ t.Fatalf("expected 3 samples, got %d", s.Count) ++ } ++ if s.Min != 100*time.Millisecond { ++ t.Fatalf("expected min 100ms, got %v", s.Min) ++ } ++ if s.Max != 300*time.Millisecond { ++ t.Fatalf("expected max 300ms, got %v", s.Max) ++ } ++ if s.Avg != 200*time.Millisecond { ++ t.Fatalf("expected avg 200ms, got %v", s.Avg) ++ } ++} ++ ++func TestLatencyTrackerSince(t *testing.T) { ++ r := NewRegistry() ++ lt := r.Latency("test.since") ++ ++ start := time.Now().Add(-50 * time.Millisecond) ++ lt.Since(start) ++ ++ s := lt.Summary() ++ if s.Count != 1 { ++ t.Fatalf("expected 1 sample, got %d", s.Count) ++ } ++ if s.Min < 40*time.Millisecond { ++ t.Fatalf("expected min >= 40ms, got %v", s.Min) ++ } ++} ++ ++func TestLatencyTrackerPercentiles(t *testing.T) { ++ r := NewRegistry() ++ lt := r.Latency("test.percentiles") ++ ++ // Add 100 samples: 1ms, 2ms, ..., 100ms ++ for i := 1; i <= 100; i++ { ++ lt.Observe(time.Duration(i) * time.Millisecond) ++ } ++ ++ s := lt.Summary() ++ if s.Count != 100 { ++ t.Fatalf("expected 100 samples, got %d", s.Count) ++ } ++ if s.P50 < 45*time.Millisecond || s.P50 > 55*time.Millisecond { ++ t.Fatalf("expected P50 around 50ms, got %v", s.P50) ++ } ++ if s.P95 < 90*time.Millisecond { ++ t.Fatalf("expected P95 >= 90ms, got %v", s.P95) ++ } ++ if s.P99 < 95*time.Millisecond { ++ t.Fatalf("expected P99 >= 95ms, got %v", s.P99) ++ } ++} ++ ++func TestLatencyTrackerEmptySummary(t *testing.T) { ++ r := NewRegistry() ++ lt := r.Latency("test.empty") ++ ++ s := lt.Summary() ++ if s.Count != 0 { ++ t.Fatalf("expected 0 samples, got %d", s.Count) ++ } ++ if s.Min != 0 || s.Max != 0 || s.Avg != 0 { ++ t.Fatalf("expected zero durations for empty tracker, got min=%v max=%v avg=%v", s.Min, s.Max, s.Avg) ++ } ++} ++ ++func TestRegistrySnapshot(t *testing.T) { ++ r := NewRegistry() ++ c := r.Counter("snap.counter") ++ g := r.Gauge("snap.gauge") ++ lt := r.Latency("snap.latency") ++ ++ c.Inc() ++ c.Inc() ++ g.Set(42) ++ lt.Observe(10 * time.Millisecond) ++ ++ snap := r.Snapshot() ++ if snap.Counters["snap.counter"] != 2 { ++ t.Fatalf("expected counter=2, got %d", snap.Counters["snap.counter"]) ++ } ++ if snap.Gauges["snap.gauge"] != 42 { ++ t.Fatalf("expected gauge=42, got %d", snap.Gauges["snap.gauge"]) ++ } ++ latSummary, ok := snap.Latency["snap.latency"] ++ if !ok { ++ t.Fatal("expected latency summary in snapshot") ++ } ++ if latSummary.Count != 1 { ++ t.Fatalf("expected 1 latency sample, got %d", latSummary.Count) ++ } ++} ++ ++func TestDefaultRegistry(t *testing.T) { ++ d := Default() ++ if d == nil { ++ t.Fatal("expected non-nil default registry") ++ } ++ // Same instance ++ if Default() != d { ++ t.Fatal("expected same default registry instance") ++ } ++} ++ ++func TestLogEvent(t *testing.T) { ++ // Just verify it doesn't panic ++ LogEvent("test.event", "sess-1", "req-1", map[string]interface{}{ ++ "key": "value", ++ }) ++ LogEvent("test.event", "", "", nil) ++} ++ ++func TestPredefinedMetricNames(t *testing.T) { ++ // Verify predefined names are non-empty and follow naming convention ++ names := []string{ ++ SessionCreated, SessionDeleted, SessionActive, SessionPromptLatency, ++ RequestTotal, RequestLatency, RequestErrors, ++ ToolCallCount, ToolCallDuration, ToolCallErrors, ++ PermissionDenials, ++ AgentExecDuration, AgentExecFailures, ++ OutputSize, ++ JobSubmitted, JobCompleted, JobFailed, JobDuration, ++ WSConnections, WSSubscribers, WSDroppedSubs, ++ } ++ for _, name := range names { ++ if name == "" { ++ t.Fatal("empty metric name") ++ } ++ } ++} + + succeeded in 0ms: +.autoresearch/workflows/issue-11/log.md +internal/agent/agent.go +internal/gateway/server.go +internal/gateway/stream_hub.go +internal/metrics/metrics.go +internal/metrics/metrics_test.go + +exec +/bin/zsh -lc "nl -ba /Users/chaoyuepan/ai/imclaw/internal/metrics/metrics_test.go | sed -n '1,360p'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: + 1 package metrics + 2 + 3 import ( + 4 "testing" + 5 "time" + 6 ) + 7 + 8 func TestCounterInc(t *testing.T) { + 9 r := NewRegistry() + 10 c := r.Counter("test.counter") + 11 + 12 if v := c.Value(); v != 0 { + 13 t.Fatalf("expected initial value 0, got %d", v) + 14 } + 15 c.Inc() + 16 if v := c.Value(); v != 1 { + 17 t.Fatalf("expected 1 after Inc, got %d", v) + 18 } + 19 } + 20 + 21 func TestCounterAdd(t *testing.T) { + 22 r := NewRegistry() + 23 c := r.Counter("test.counter_add") + 24 + 25 c.Add(5) + 26 if v := c.Value(); v != 5 { + 27 t.Fatalf("expected 5 after Add(5), got %d", v) + 28 } + 29 c.Add(3) + 30 if v := c.Value(); v != 8 { + 31 t.Fatalf("expected 8 after Add(3), got %d", v) + 32 } + 33 } + 34 + 35 func TestCounterAddNegative(t *testing.T) { + 36 r := NewRegistry() + 37 c := r.Counter("test.counter_neg") + 38 + 39 c.Add(10) + 40 c.Add(-5) // should be ignored + 41 if v := c.Value(); v != 10 { + 42 t.Fatalf("expected 10 after Add(-5) ignored, got %d", v) + 43 } + 44 } + 45 + 46 func TestCounterRegistryReturnsSame(t *testing.T) { + 47 r := NewRegistry() + 48 c1 := r.Counter("same_name") + 49 c2 := r.Counter("same_name") + 50 + 51 c1.Inc() + 52 if v := c2.Value(); v != 1 { + 53 t.Fatalf("expected shared counter to be 1, got %d", v) + 54 } + 55 } + 56 + 57 func TestGaugeSet(t *testing.T) { + 58 r := NewRegistry() + 59 g := r.Gauge("test.gauge") + 60 + 61 g.Set(42) + 62 if v := g.Value(); v != 42 { + 63 t.Fatalf("expected 42 after Set, got %d", v) + 64 } + 65 g.Set(100) + 66 if v := g.Value(); v != 100 { + 67 t.Fatalf("expected 100 after Set, got %d", v) + 68 } + 69 } + 70 + 71 func TestGaugeIncDec(t *testing.T) { + 72 r := NewRegistry() + 73 g := r.Gauge("test.gauge_incdec") + 74 + 75 g.Inc() + 76 g.Inc() + 77 if v := g.Value(); v != 2 { + 78 t.Fatalf("expected 2 after two Inc, got %d", v) + 79 } + 80 g.Dec() + 81 if v := g.Value(); v != 1 { + 82 t.Fatalf("expected 1 after Dec, got %d", v) + 83 } + 84 } + 85 + 86 func TestLatencyTrackerObserve(t *testing.T) { + 87 r := NewRegistry() + 88 lt := r.Latency("test.latency") + 89 + 90 lt.Observe(100 * time.Millisecond) + 91 lt.Observe(200 * time.Millisecond) + 92 lt.Observe(300 * time.Millisecond) + 93 + 94 s := lt.Summary() + 95 if s.Count != 3 { + 96 t.Fatalf("expected 3 samples, got %d", s.Count) + 97 } + 98 if s.Min != 100*time.Millisecond { + 99 t.Fatalf("expected min 100ms, got %v", s.Min) + 100 } + 101 if s.Max != 300*time.Millisecond { + 102 t.Fatalf("expected max 300ms, got %v", s.Max) + 103 } + 104 if s.Avg != 200*time.Millisecond { + 105 t.Fatalf("expected avg 200ms, got %v", s.Avg) + 106 } + 107 } + 108 + 109 func TestLatencyTrackerSince(t *testing.T) { + 110 r := NewRegistry() + 111 lt := r.Latency("test.since") + 112 + 113 start := time.Now().Add(-50 * time.Millisecond) + 114 lt.Since(start) + 115 + 116 s := lt.Summary() + 117 if s.Count != 1 { + 118 t.Fatalf("expected 1 sample, got %d", s.Count) + 119 } + 120 if s.Min < 40*time.Millisecond { + 121 t.Fatalf("expected min >= 40ms, got %v", s.Min) + 122 } + 123 } + 124 + 125 func TestLatencyTrackerPercentiles(t *testing.T) { + 126 r := NewRegistry() + 127 lt := r.Latency("test.percentiles") + 128 + 129 // Add 100 samples: 1ms, 2ms, ..., 100ms + 130 for i := 1; i <= 100; i++ { + 131 lt.Observe(time.Duration(i) * time.Millisecond) + 132 } + 133 + 134 s := lt.Summary() + 135 if s.Count != 100 { + 136 t.Fatalf("expected 100 samples, got %d", s.Count) + 137 } + 138 if s.P50 < 45*time.Millisecond || s.P50 > 55*time.Millisecond { + 139 t.Fatalf("expected P50 around 50ms, got %v", s.P50) + 140 } + 141 if s.P95 < 90*time.Millisecond { + 142 t.Fatalf("expected P95 >= 90ms, got %v", s.P95) + 143 } + 144 if s.P99 < 95*time.Millisecond { + 145 t.Fatalf("expected P99 >= 95ms, got %v", s.P99) + 146 } + 147 } + 148 + 149 func TestLatencyTrackerEmptySummary(t *testing.T) { + 150 r := NewRegistry() + 151 lt := r.Latency("test.empty") + 152 + 153 s := lt.Summary() + 154 if s.Count != 0 { + 155 t.Fatalf("expected 0 samples, got %d", s.Count) + 156 } + 157 if s.Min != 0 || s.Max != 0 || s.Avg != 0 { + 158 t.Fatalf("expected zero durations for empty tracker, got min=%v max=%v avg=%v", s.Min, s.Max, s.Avg) + 159 } + 160 } + 161 + 162 func TestLatencyTrackerRingBuffer(t *testing.T) { + 163 r := NewRegistry() + 164 lt := r.Latency("test.ring") + 165 + 166 // Fill beyond maxSamples + 167 for i := 0; i < maxSamples+200; i++ { + 168 lt.Observe(time.Duration(i) * time.Millisecond) + 169 } + 170 + 171 s := lt.Summary() + 172 if s.Count != maxSamples { + 173 t.Fatalf("expected %d samples (capped), got %d", maxSamples, s.Count) + 174 } + 175 // After writing maxSamples+200 entries, the ring buffer should contain + 176 // the last maxSamples entries: 200..1199 + 177 // Min should be 200ms + 178 if s.Min != 200*time.Millisecond { + 179 t.Fatalf("expected min 200ms (oldest retained), got %v", s.Min) + 180 } + 181 if s.Max != 1199*time.Millisecond { + 182 t.Fatalf("expected max 1199ms (newest), got %v", s.Max) + 183 } + 184 } + 185 + 186 func TestLatencyTrackerRingBufferExactlyFull(t *testing.T) { + 187 r := NewRegistry() + 188 lt := r.Latency("test.ring_exact") + 189 + 190 // Fill exactly maxSamples + 191 for i := 0; i < maxSamples; i++ { + 192 lt.Observe(time.Duration(i) * time.Millisecond) + 193 } + 194 + 195 s := lt.Summary() + 196 if s.Count != maxSamples { + 197 t.Fatalf("expected %d samples, got %d", maxSamples, s.Count) + 198 } + 199 if s.Min != 0 { + 200 t.Fatalf("expected min 0, got %v", s.Min) + 201 } + 202 if s.Max != (maxSamples-1)*time.Millisecond { + 203 t.Fatalf("expected max %dms, got %v", maxSamples-1, s.Max) + 204 } + 205 } + 206 + 207 func TestLatencyTrackerConcurrentObserve(t *testing.T) { + 208 r := NewRegistry() + 209 lt := r.Latency("test.concurrent") + 210 + 211 done := make(chan struct{}) + 212 const goroutines = 10 + 213 const perGoroutine = 100 + 214 + 215 for g := 0; g < goroutines; g++ { + 216 go func() { + 217 defer func() { done <- struct{}{} }() + 218 for i := 0; i < perGoroutine; i++ { + 219 lt.Observe(time.Duration(i) * time.Millisecond) + 220 } + 221 }() + 222 } + 223 + 224 for g := 0; g < goroutines; g++ { + 225 <-done + 226 } + 227 + 228 s := lt.Summary() + 229 expected := goroutines * perGoroutine + 230 if expected > maxSamples { + 231 expected = maxSamples + 232 } + 233 if s.Count != expected { + 234 t.Fatalf("expected %d samples, got %d", expected, s.Count) + 235 } + 236 } + 237 + 238 func TestRegistrySnapshot(t *testing.T) { + 239 r := NewRegistry() + 240 c := r.Counter("snap.counter") + 241 g := r.Gauge("snap.gauge") + 242 lt := r.Latency("snap.latency") + 243 + 244 c.Inc() + 245 c.Inc() + 246 g.Set(42) + 247 lt.Observe(10 * time.Millisecond) + 248 + 249 snap := r.Snapshot() + 250 if snap.Counters["snap.counter"] != 2 { + 251 t.Fatalf("expected counter=2, got %d", snap.Counters["snap.counter"]) + 252 } + 253 if snap.Gauges["snap.gauge"] != 42 { + 254 t.Fatalf("expected gauge=42, got %d", snap.Gauges["snap.gauge"]) + 255 } + 256 latSummary, ok := snap.Latency["snap.latency"] + 257 if !ok { + 258 t.Fatal("expected latency summary in snapshot") + 259 } + 260 if latSummary.Count != 1 { + 261 t.Fatalf("expected 1 latency sample, got %d", latSummary.Count) + 262 } + 263 } + 264 + 265 func TestDefaultRegistry(t *testing.T) { + 266 d := Default() + 267 if d == nil { + 268 t.Fatal("expected non-nil default registry") + 269 } + 270 // Same instance + 271 if Default() != d { + 272 t.Fatal("expected same default registry instance") + 273 } + 274 } + 275 + 276 func TestLogEvent(t *testing.T) { + 277 // Just verify it doesn't panic + 278 LogEvent("test.event", "sess-1", "req-1", map[string]interface{}{ + 279 "key": "value", + 280 }) + 281 LogEvent("test.event", "", "", nil) + 282 } + 283 + 284 func TestPredefinedMetricNames(t *testing.T) { + 285 // Verify predefined names are non-empty and follow naming convention + 286 names := []string{ + 287 SessionCreated, SessionDeleted, SessionActive, SessionPromptLatency, + 288 RequestTotal, RequestLatency, RequestErrors, + 289 ToolCallCount, ToolCallDuration, ToolCallErrors, + 290 PermissionDenials, + 291 AgentExecDuration, AgentExecFailures, + 292 OutputSize, + 293 JobSubmitted, JobCompleted, JobFailed, JobDuration, + 294 WSConnections, WSSubscribers, WSDroppedSubs, + 295 } + 296 for _, name := range names { + 297 if name == "" { + 298 t.Fatal("empty metric name") + 299 } + 300 } + 301 } + 302 + 303 func TestTruncate(t *testing.T) { + 304 tests := []struct { + 305 input string + 306 maxLen int + 307 want string + 308 }{ + 309 {"hello", 10, "hello"}, + 310 {"hello world", 5, "hello..."}, + 311 {"", 5, ""}, + 312 {"abc", 3, "abc"}, + 313 } + 314 for _, tt := range tests { + 315 got := Truncate(tt.input, tt.maxLen) + 316 if got != tt.want { + 317 t.Errorf("Truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) + 318 } + 319 } + 320 } + +exec +/bin/zsh -lc "nl -ba /Users/chaoyuepan/ai/imclaw/internal/gateway/server.go | sed -n '1380,1465p'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: + 1380 if !ok { + 1381 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: nil} + 1382 } + 1383 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: sess} + 1384 } + 1385 + 1386 func (s *Server) handleSessionList(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 1387 _ = connID + 1388 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"sessions": s.sessionMgr.Summaries()}} + 1389 } + 1390 + 1391 func (s *Server) handleSessionUpdate(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 1392 _ = connID + 1393 params, ok := req.Params.(map[string]interface{}) + 1394 if !ok { + 1395 return invalidParams(req.ID) + 1396 } + 1397 + 1398 sessionID := getStringParam(params, "session_id") + 1399 if sessionID == "" { + 1400 return missingParam(req.ID, "session_id") + 1401 } + 1402 + 1403 sess, ok := s.sessionMgr.Get(sessionChannelFromParams(params), sessionID) + 1404 if !ok { + 1405 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &JSONRPCError{Code: -32602, Message: "Session not found"}} + 1406 } + 1407 if agentType := getStringParam(params, "agent"); agentType != "" { + 1408 sess.AgentName = agentType + 1409 } + 1410 s.sessionMgr.Update(sess) + 1411 updated, _ := s.sessionMgr.Get(sessionChannelFromParams(params), sessionID) + 1412 s.broadcastSession(updated) + 1413 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: updated} + 1414 } + 1415 + 1416 func (s *Server) handleSessionDelete(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 1417 _ = connID + 1418 params, ok := req.Params.(map[string]interface{}) + 1419 if !ok { + 1420 return invalidParams(req.ID) + 1421 } + 1422 + 1423 sessionID := getStringParam(params, "session_id") + 1424 s.sessionMgr.Delete(sessionChannelFromParams(params), sessionID) + 1425 s.broadcastSessionDeleted(sessionID) + 1426 metrics.Default().Counter(metrics.SessionDeleted).Inc() + 1427 metrics.Default().Gauge(metrics.SessionActive).Set(int64(len(s.sessionMgr.Summaries()))) + 1428 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"success": true}} + 1429 } + 1430 + 1431 func (s *Server) handleSessionRename(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 1432 _ = connID + 1433 params, ok := req.Params.(map[string]interface{}) + 1434 if !ok { + 1435 return invalidParams(req.ID) + 1436 } + 1437 sessionID := getStringParam(params, "session_id") + 1438 name := getStringParam(params, "name") + 1439 if sessionID == "" { + 1440 return missingParam(req.ID, "session_id") + 1441 } + 1442 if name == "" { + 1443 return missingParam(req.ID, "name") + 1444 } + 1445 sess, found := s.sessionMgr.Rename(sessionChannelFromParams(params), sessionID, name) + 1446 if !found { + 1447 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &JSONRPCError{Code: -32602, Message: "Session not found"}} + 1448 } + 1449 s.broadcastSession(sess) + 1450 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: sess} + 1451 } + 1452 + 1453 func (s *Server) handleSessionTag(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 1454 _ = connID + 1455 params, ok := req.Params.(map[string]interface{}) + 1456 if !ok { + 1457 return invalidParams(req.ID) + 1458 } + 1459 sessionID := getStringParam(params, "session_id") + 1460 tag := getStringParam(params, "tag") + 1461 if sessionID == "" { + 1462 return missingParam(req.ID, "session_id") + 1463 } + 1464 if tag == "" { + 1465 return missingParam(req.ID, "tag") + +exec +/bin/zsh -lc "rg -n \"func truncate\\(|metrics\\.Truncate\\(\" /Users/chaoyuepan/ai/imclaw/internal/gateway/server.go /Users/chaoyuepan/ai/imclaw/internal/agent/agent.go" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:449: log.Printf("[acpx] Prompt: %s", metrics.Truncate(prompt, 200)) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:478: log.Printf("[acpx] Prompt: %s", metrics.Truncate(prompt, 200)) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:567: "error": metrics.Truncate(err.Error(), 200), +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1134: "error_message": metrics.Truncate(message, 200), + +codex +我还需要对照 Issue 的验收口径,避免只按“代码看起来可行”打分;我会读取本地的 issue 工作日志,确认这次功能原始要求里是否强调了“准确计数/低开销/可用于 dashboard”。 +exec +/bin/zsh -lc "sed -n '1,240p' /Users/chaoyuepan/ai/imclaw/.autoresearch/workflows/issue-11/log.md" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: +# Issue #11 实现日志 + +## 基本信息 +- Issue: #11 - feat: add observability for sessions, tools, and agent execution +- 项目: /Users/chaoyuepan/ai/imclaw +- 语言: go +- 开始时间: 2026-04-18 15:19:43 +- 标签: + +## 迭代记录 + +### 迭代 1 (2026-04-18) + +#### 实现摘要 + +为 IMClaw 添加了可观测性系统,通过结构化日志实现,不依赖外部 tracing 系统。 + +#### 新增文件 +- `internal/metrics/metrics.go` — 核心指标包,包含 Counter、Gauge、LatencyTracker、Registry 和 LogEvent +- `internal/metrics/metrics_test.go` — 指标包测试,覆盖率 96.7% + +#### 修改文件 +- `internal/gateway/server.go` — 添加了: + - RPC 请求计数、延迟追踪 (`request.total`, `request.latency`, `request.errors`) + - 会话创建、活跃计数 (`session.created`, `session.active_count`) + - Prompt 延迟追踪 (`session.prompt_latency`) + - 输出大小追踪 (`output.size_bytes`) + - 工具调用计数、持续时间、错误 (`tool.call_count`, `tool.call_duration`, `tool.call_errors`) + - Agent 执行失败计数 (`agent.exec_failures`) + - Job 提交/完成/失败追踪 + - WebSocket 连接活跃计数 + - 结构化事件日志 (`session.prompt`, `session.result`, `session.error`, `tool.start`, `tool.end`, `job.failed`) +- `internal/gateway/stream_hub.go` — 添加了: + - 订阅者活跃计数 (`ws.active_subscribers`) + - 慢订阅者丢弃计数 (`ws.dropped_subscribers`) +- `internal/agent/agent.go` — 添加了: + - Agent 执行延迟 (`agent.exec_duration`) + - 权限拒绝计数 (`permission.denials`) + - 权限拒绝事件日志 + - 流式请求的持续时间追踪 + +#### 测试结果 +- 所有测试通过 +- metrics 包覆盖率: 96.7% +- go vet 无错误 + +#### 关键指标 + +| 类别 | 指标名 | 类型 | 说明 | +|------|--------|------|------| +| Session | session.created | Counter | 会话创建次数 | +| Session | session.active_count | Gauge | 当前活跃会话数 | +| Session | session.prompt_latency | Latency | Prompt 处理延迟 | +| Request | request.total | Counter | RPC 请求总数 | +| Request | request.latency | Latency | RPC 请求延迟 | +| Request | request.errors | Counter | RPC 错误数 | +| Tool | tool.call_count | Counter | 工具调用次数 | +| Tool | tool.call_duration | Latency | 工具调用持续时间 | +| Tool | tool.call_errors | Counter | 工具调用错误数 | +| Permission | permission.denials | Counter | 权限拒绝次数 | +| Agent | agent.exec_duration | Latency | Agent 执行延迟 | +| Agent | agent.exec_failures | Counter | Agent 执行失败数 | +| Output | output.size_bytes | Counter | 输出总字节数 | +| Job | job.submitted | Counter | Job 提交数 | +| Job | job.completed | Counter | Job 完成数 | +| Job | job.failed | Counter | Job 失败数 | +| Job | job.duration | Latency | Job 执行延迟 | +| WS | ws.active_connections | Gauge | WebSocket 活跃连接数 | +| WS | ws.active_subscribers | Gauge | 活跃订阅者数 | +| WS | ws.dropped_subscribers | Counter | 丢弃的慢订阅者数 | + +### 迭代 1 - Claude (实现) + +详见: [iteration-1-claude.log](./iteration-1-claude.log) +- 测试: ✅ 通过 +- 审核评分 (Claude): 82/100 + +### 迭代 2 - Claude (实现) + +详见: [iteration-2-claude.log](./iteration-2-claude.log) +- 测试: ✅ 通过 + +exec +/bin/zsh -lc "sed -n '1,260p' /Users/chaoyuepan/ai/imclaw/.autoresearch/workflows/issue-11/iteration-3-codex-review.log" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: +OpenAI Codex v0.121.0 (research preview) +-------- +workdir: /Users/chaoyuepan/ai/imclaw +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: workspace-write [workdir, /tmp, $TMPDIR, /Users/chaoyuepan/.codex/memories] +reasoning effort: medium +reasoning summaries: none +session id: 019d9fa5-0e06-7de1-adec-f3eafc5f15fd +-------- +user +审核 Issue #11 的实现 + +项目路径: /Users/chaoyuepan/ai/imclaw +项目语言: go +Issue 标题: feat: add observability for sessions, tools, and agent execution + +--- +请审核代码并给出评分和改进建议: +# Codex Agent + +你是一个专业的软件工程师 Agent,既能实现功能,也能审核代码。 + +--- + +## 角色定位 + +**你可以是实现者,也可以是审核者,取决于任务要求。** + +- 作为实现者:根据 Issue 描述或审核反馈实现/改进代码 +- 作为审核者:审查代码质量,给出评分和改进建议 +- 你需要编写代码和测试 +- 你需要接受审核反馈并改进 + +--- + +## 工作流程 + +### Phase 1: 理解需求 + +``` +1. 阅读 Issue #N 的完整内容 +2. 理解 Issue 的核心诉求 +3. 如果有疑问,列出需要澄清的问题 +4. 确认涉及的代码模块 +``` + +### Phase 2: 分析代码 + +``` +1. 使用 Glob 和 Grep 工具搜索相关代码 +2. 阅读相关文件,理解现有架构 +3. 识别需要修改的文件 +4. 评估改动范围和影响 +``` + +### Phase 3: 实现代码 + +``` +1. 编写功能实现代码 +2. 编写单元测试代码 +3. 确保测试覆盖核心逻辑 +4. 运行测试验证实现 +``` + +--- + +## 输出格式 + +每次实现完成后,你必须输出以下结构: + +```markdown +## 实现报告 + +### Issue +- 编号: #N +- 标题: [Issue 标题] +- 类型: feature / bugfix / refactor / docs + +### 改动概述 +- 修改文件: [文件列表] +- 新增文件: [文件列表] +- 删除文件: [文件列表] +- 代码行数: +X / -Y + +### 实现思路 +[描述你的实现思路,关键设计决策] + +### 测试情况 +- 测试文件: [测试文件路径] +- 测试用例数: N +- 覆盖场景: + - [场景1] + - [场景2] + - ... + +### 待确认问题 +- [列出需要审核者关注的问题,如果没有则写"无"] +``` + +--- + +## 代码规范 + +### Go 代码规范 + +```go +// 1. 包注释 +// Package parser 提供事件流解析功能。 +package parser + +// 2. 函数注释 +// Parse 解析输入数据并返回事件列表。 +// 如果输入格式无效,返回 ErrInvalidInput 错误。 +func Parse(input []byte) ([]Event, error) { ... } + +// 3. 错误处理 +if err != nil { + return fmt.Errorf("parse failed: %w", err) +} + +// 4. 表格驱动测试 +func TestParse(t *testing.T) { + tests := []struct { + name string + input string + want []Event + wantErr bool + }{ + {"valid input", `{"type":"start"}`, []Event{{Type: "start"}}, false}, + {"invalid json", `{invalid}`, nil, true}, + {"empty input", ``, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse([]byte(tt.input)) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() = %v, want %v", got, tt.want) + } + }) + } +} +``` + +### 禁止事项 + +``` +❌ 不要使用 panic(除非在不可恢复的错误场景) +❌ 不要忽略错误返回值 +❌ 不要使用全局变量 +❌ 不要在测试中使用 time.Sleep +❌ 不要硬编码配置值 +❌ 不要提交注释掉的代码 +❌ 不要使用魔法数字 +``` + +--- + +## 面对审核反馈 + +当审核者 (Claude) 提出批评意见时: + +### 1. 理解反馈 + +```markdown +## 反馈理解 + +审核者提出以下问题: +1. [问题1] - 我的理解:[描述理解] +2. [问题2] - 我的理解:[描述理解] + +我是否正确理解了反馈? +``` + +### 2. 分类处理 + +``` +| 反馈类型 | 处理方式 | +|---------|---------| +| 严重 Bug | 立即修复 | +| 设计问题 | 评估影响后修复 | +| 代码风格 | 按规范修改 | +| 性能问题 | 评估必要性后处理 | +| 建议性意见 | 根据实际情况决定是否采纳 | +``` + +### 3. 改进报告 + +```markdown +## 改进报告 + +### 已修复问题 +- [问题1]: [修复方案] +- [问题2]: [修复方案] + +### 未采纳建议 +- [建议]: [原因] + +### 新增改动 +- [描述新增的改动] +``` + +--- + +## 特殊情况处理 + +### 无法实现 + +如果 Issue 描述不清或技术上不可行: + +```markdown +## 无法实现报告 + +### Issue +- 编号: #N + +### 阻塞原因 +- [ ] 需求不明确 +- [ ] 缺少必要上下文 +- [ ] 技术限制 +- [ ] 依赖未解决 + +### 详细说明 +[描述具体原因] + +### 建议操作 +- [建议人类如何处理这个 Issue] +``` + +### 需要设计决策 + +如果 Issue 涉及架构级别的决策: + +```markdown +## 需要设计评审 + +### Issue +- 编号: #N + +### 设计问题 +[描述需要决策的问题] + +### 可选方案 +1. 方案A: [描述] - 优点: [] 缺点: [] +2. 方案B: [描述] - 优点: [] 缺点: [] + +### 建议 +[你的建议和理由] + +### 请人工确认后继续 +``` + +--- + +## 迭代限制 + + +codex +我已经确认了两个明确问题(`session.created` 计数语义错误、`agent.exec_failures` 在流式启动失败时重复计数)。我再快速核查事件模型,确认 `tool.call_errors` 的统计逻辑是否也存在系统性漏计或误计。 +exec +/bin/zsh -lc "nl -ba /Users/chaoyuepan/ai/imclaw/internal/event/parser.go | sed -n '1,260p'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: + 1 package event + 2 + 3 import ( + 4 "bytes" + 5 "strings" + 6 "unicode" + 7 "unicode/utf8" + 8 ) + 9 + 10 // Parser parses IMClaw transcript output and emits structured events. + 11 // It recognizes fine-grained tool lifecycle events like tool_start and tool_end. + 12 type Parser struct { + 13 buf bytes.Buffer + 14 currentType Type + 15 currentBuf bytes.Buffer + 16 // Tool state + 17 toolName string + 18 toolInput bytes.Buffer + 19 toolOutput bytes.Buffer + 20 pendingTool string // Tool name waiting for completion + 21 collectOutput bool // Collecting tool output after (completed) + 22 } + 23 + 24 // NewParser creates a new Parser. + 25 func NewParser() *Parser { + 26 return &Parser{} + 27 } + 28 + 29 // Parse converts a full IMClaw transcript into structured events. + 30 func Parse(raw string) []Event { + 31 p := NewParser() + 32 events := p.Feed(raw) + 33 events = append(events, p.Flush()...) + 34 return events + 35 } + 36 + 37 // Feed consumes a transcript chunk and returns any completed events. + 38 func (p *Parser) Feed(chunk string) []Event { + 39 chunk = normalizeChunk(chunk) + 40 if chunk == "" { + 41 return nil + 42 } + 43 + 44 p.buf.WriteString(chunk) + 45 + 46 var events []Event + 47 for { + 48 line, found := p.readLine() + 49 if !found { + 50 break + 51 } + 52 events = append(events, p.processLine(line)...) + 53 } + 54 + 55 return events + 56 } + 57 + 58 // Flush emits any remaining events from buffered content. + 59 func (p *Parser) Flush() []Event { + 60 var events []Event + 61 + 62 // Process any remaining buffered content + 63 if p.buf.Len() > 0 { + 64 events = append(events, p.processLine(p.buf.String())...) + 65 p.buf.Reset() + 66 } + 67 + 68 // Flush any pending tool + 69 if p.pendingTool != "" { + 70 events = append(events, Event{ + 71 Type: TypeToolEnd, + 72 Name: p.pendingTool, + 73 Input: strings.TrimSpace(p.toolInput.String()), + 74 Output: strings.TrimSpace(p.toolOutput.String()), + 75 }) + 76 p.pendingTool = "" + 77 p.toolInput.Reset() + 78 p.toolOutput.Reset() + 79 } + 80 + 81 // Flush current block + 82 if event, ok := p.flushCurrent(); ok { + 83 events = append(events, event) + 84 } + 85 + 86 return events + 87 } + 88 + 89 func (p *Parser) readLine() (string, bool) { + 90 b := p.buf.Bytes() + 91 idx := bytes.IndexByte(b, '\n') + 92 if idx < 0 { + 93 return "", false + 94 } + 95 line := string(b[:idx]) + 96 p.buf.Next(idx + 1) + 97 return line, true + 98 } + 99 + 100 func (p *Parser) processLine(line string) []Event { + 101 var events []Event + 102 + 103 // If collecting tool output, check if this line continues it + 104 if p.collectOutput { + 105 if line == "" || startsWithWhitespace(line) { + 106 p.toolOutput.WriteString(line) + 107 p.toolOutput.WriteByte('\n') + 108 return events + 109 } + 110 // Non-indented line ends tool output + 111 events = append(events, Event{ + 112 Type: TypeToolEnd, + 113 Name: p.pendingTool, + 114 Input: strings.TrimSpace(p.toolInput.String()), + 115 Output: strings.TrimSpace(p.toolOutput.String()), + 116 }) + 117 p.pendingTool = "" + 118 p.toolInput.Reset() + 119 p.toolOutput.Reset() + 120 p.collectOutput = false + 121 } + 122 + 123 // Check for marker: [type] content + 124 if markerType, content, isMarker := parseMarker(line); isMarker { + 125 // Flush any current block first + 126 if event, ok := p.flushCurrent(); ok { + 127 events = append(events, event) + 128 } + 129 + 130 switch markerType { + 131 case "thinking": + 132 p.currentType = TypeThinking + 133 if content != "" { + 134 p.currentBuf.WriteString(content) + 135 } + 136 + 137 case "tool": + 138 events = append(events, p.parseToolMarker(content)...) + 139 + 140 case "done": + 141 p.currentType = "" + 142 + 143 default: + 144 p.currentType = "" + 145 } + 146 + 147 return events + 148 } + 149 + 150 // Handle continuation lines based on current type + 151 switch p.currentType { + 152 case TypeThinking: + 153 if line == "" || startsWithWhitespace(line) { + 154 if p.currentBuf.Len() > 0 { + 155 p.currentBuf.WriteByte('\n') + 156 } + 157 p.currentBuf.WriteString(line) + 158 return events + 159 } + 160 // Non-indented line ends thinking block + 161 if event, ok := p.flushCurrent(); ok { + 162 events = append(events, event) + 163 } + 164 p.currentType = TypeOutput + 165 p.currentBuf.WriteString(line) + 166 + 167 case TypeToolInput: + 168 // Collect tool input lines (indented after tool_start) + 169 if startsWithWhitespace(line) || line == "" { + 170 p.toolInput.WriteString(line) + 171 p.toolInput.WriteByte('\n') + 172 return events + 173 } + 174 // Non-indented line ends tool input, start output + 175 p.currentType = TypeOutput + 176 p.currentBuf.WriteString(line) + 177 + 178 case TypeOutput: + 179 if p.currentBuf.Len() > 0 { + 180 p.currentBuf.WriteByte('\n') + 181 } + 182 p.currentBuf.WriteString(line) + 183 + 184 default: + 185 if strings.TrimSpace(line) == "" { + 186 return events + 187 } + 188 p.currentType = TypeOutput + 189 p.currentBuf.WriteString(line) + 190 } + 191 + 192 return events + 193 } + 194 + 195 // parseToolMarker parses a tool marker line and emits appropriate events. + 196 func (p *Parser) parseToolMarker(content string) []Event { + 197 var events []Event + 198 content = strings.TrimSpace(content) + 199 + 200 var toolName, state string + 201 if strings.HasSuffix(content, "(pending)") { + 202 toolName = strings.TrimSpace(strings.TrimSuffix(content, "(pending)")) + 203 state = "pending" + 204 } else if strings.HasSuffix(content, "(completed)") { + 205 toolName = strings.TrimSpace(strings.TrimSuffix(content, "(completed)")) + 206 state = "completed" + 207 } else if strings.HasSuffix(content, "(error)") { + 208 toolName = strings.TrimSpace(strings.TrimSuffix(content, "(error)")) + 209 state = "error" + 210 } else { + 211 // Unknown format + 212 return []Event{{Type: TypeToolStart, Content: content}} + 213 } + 214 + 215 switch state { + 216 case "pending": + 217 p.toolName = toolName + 218 p.toolInput.Reset() + 219 p.toolOutput.Reset() + 220 p.currentType = TypeToolInput + 221 events = append(events, Event{ + 222 Type: TypeToolStart, + 223 Name: toolName, + 224 }) + 225 + 226 case "completed": + 227 // Emit tool_end after collecting indented output lines + 228 p.pendingTool = toolName + 229 p.collectOutput = true + 230 p.currentType = "" + 231 + 232 case "error": + 233 events = append(events, Event{ + 234 Type: TypeToolError, + 235 Name: toolName, + 236 Input: strings.TrimSpace(p.toolInput.String()), + 237 }) + 238 p.toolName = "" + 239 p.toolInput.Reset() + 240 p.toolOutput.Reset() + 241 p.currentType = "" + 242 } + 243 + 244 return events + 245 } + 246 + 247 func (p *Parser) flushCurrent() (Event, bool) { + 248 if p.currentType == "" { + 249 p.currentBuf.Reset() + 250 return Event{}, false + 251 } + 252 + 253 content := trimBlankLines(p.currentBuf.String()) + 254 eventType := p.currentType + 255 + 256 p.currentType = "" + 257 p.currentBuf.Reset() + 258 + 259 if content == "" { + 260 return Event{}, false + +exec +/bin/zsh -lc "nl -ba /Users/chaoyuepan/ai/imclaw/internal/gateway/server.go | sed -n '1,220p'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: + 1 package gateway + 2 + 3 import ( + 4 "context" + 5 "crypto/subtle" + 6 "encoding/json" + 7 "fmt" + 8 "log" + 9 "net/http" + 10 "strings" + 11 "sync" + 12 "time" + 13 + 14 "github.com/google/uuid" + 15 "github.com/gorilla/websocket" + 16 "github.com/smallnest/imclaw/internal/agent" + 17 "github.com/smallnest/imclaw/internal/event" + 18 "github.com/smallnest/imclaw/internal/job" + 19 "github.com/smallnest/imclaw/internal/metrics" + 20 "github.com/smallnest/imclaw/internal/session" + 21 ) + 22 + 23 // Config represents the server configuration. + 24 type Config struct { + 25 Host string + 26 Port int + 27 Timeout int + 28 AuthToken string + 29 DevMode bool // Enable development mode for hot-reload UI + 30 } + 31 + 32 var upgrader = websocket.Upgrader{ + 33 ReadBufferSize: 1024, + 34 WriteBufferSize: 1024, + 35 CheckOrigin: func(r *http.Request) bool { + 36 return true + 37 }, + 38 } + 39 + 40 // Server represents the gateway server. + 41 type Server struct { + 42 config *Config + 43 sessionMgr *session.Manager + 44 agentMgr *agent.Manager + 45 jobMgr *job.Manager + 46 uiHandler *uiHandler + 47 + 48 httpServer *http.Server + 49 + 50 connections map[string]*WSConnection + 51 connectionsMu sync.RWMutex + 52 + 53 hub *StreamHub + 54 + 55 running bool + 56 mu sync.RWMutex + 57 } + 58 + 59 // WSConnection represents a WebSocket connection. + 60 type WSConnection struct { + 61 *websocket.Conn + 62 ID string + 63 ctx context.Context + 64 cancel context.CancelFunc + 65 streamWG sync.WaitGroup + 66 mu sync.Mutex + 67 } + 68 + 69 const ( + 70 defaultSessionChannel = "cli" + 71 maxWSConnections = 1000 + 72 wsWriteWait = 10 * time.Second + 73 wsPongWait = 60 * time.Second + 74 wsPingPeriod = (wsPongWait * 9) / 10 + 75 ) + 76 + 77 // StreamEvent represents a structured event in the stream. + 78 type StreamEvent = agent.Event + 79 + 80 // NewServer creates a new gateway server. + 81 func NewServer(cfg *Config, sessionMgr *session.Manager, agentMgr *agent.Manager, jobMgr *job.Manager) *Server { + 82 return &Server{ + 83 config: cfg, + 84 sessionMgr: sessionMgr, + 85 agentMgr: agentMgr, + 86 jobMgr: jobMgr, + 87 uiHandler: newUIHandler(cfg != nil && cfg.DevMode), + 88 connections: make(map[string]*WSConnection), + 89 hub: NewStreamHub(), + 90 } + 91 } + 92 + 93 // Start starts the gateway server. + 94 func (s *Server) Start(ctx context.Context) error { + 95 s.mu.Lock() + 96 if s.running { + 97 s.mu.Unlock() + 98 return fmt.Errorf("server already running") + 99 } + 100 s.running = true + 101 s.mu.Unlock() + 102 + 103 go s.startServer(ctx) + 104 go func() { + 105 <-ctx.Done() + 106 _ = s.Stop() + 107 }() + 108 + 109 return nil + 110 } + 111 + 112 func (s *Server) startServer(ctx context.Context) { + 113 mux := http.NewServeMux() + 114 mux.HandleFunc("/health", s.handleHealth) + 115 mux.HandleFunc("/api/auth/check", s.handleAuthCheck) + 116 mux.HandleFunc("/api/auth/verify", s.handleAuthVerify) + 117 mux.HandleFunc("/api/sessions", s.handleSessionsAPI) + 118 mux.HandleFunc("/api/sessions/export/", s.handleSessionExportAPI) + 119 mux.HandleFunc("/api/sessions/import", s.handleSessionImportAPI) + 120 mux.HandleFunc("/api/sessions/archive/", s.handleSessionArchiveAPI) + 121 mux.HandleFunc("/api/sessions/", s.handleSessionDetailAPI) + 122 mux.HandleFunc("/api/agents", s.handleAgentsAPI) + 123 mux.HandleFunc("/api/jobs", s.handleJobsAPI) + 124 mux.HandleFunc("/api/jobs/", s.handleJobDetailAPI) + 125 mux.HandleFunc("/rpc", s.handleJSONRPC) + 126 mux.HandleFunc("/ws", s.handleWebSocket) + 127 mux.HandleFunc("/assets/", s.handleUIAssets) + 128 mux.HandleFunc("/", s.handleUI) + 129 // Build info endpoint + 130 mux.HandleFunc("/api/build", s.handleBuildInfo) + 131 + 132 s.httpServer = &http.Server{ + 133 Addr: fmt.Sprintf("%s:%d", s.config.Host, s.config.Port), + 134 Handler: mux, + 135 } + 136 + 137 go func() { + 138 log.Printf("[gateway] Server listening on %s", s.httpServer.Addr) + 139 if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + 140 log.Printf("Server error: %v", err) + 141 } + 142 }() + 143 + 144 _ = ctx + 145 } + 146 + 147 // Stop stops the gateway server. + 148 func (s *Server) Stop() error { + 149 s.mu.Lock() + 150 if !s.running { + 151 s.mu.Unlock() + 152 return nil + 153 } + 154 s.running = false + 155 s.mu.Unlock() + 156 + 157 s.closeAllConnections() + 158 if s.httpServer != nil { + 159 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + 160 defer cancel() + 161 _ = s.httpServer.Shutdown(ctx) + 162 } + 163 return nil + 164 } + 165 + 166 func (s *Server) closeAllConnections() { + 167 s.connectionsMu.Lock() + 168 defer s.connectionsMu.Unlock() + 169 + 170 for _, conn := range s.connections { + 171 _ = conn.Close() + 172 } + 173 s.connections = make(map[string]*WSConnection) + 174 } + 175 + 176 func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + 177 w.Header().Set("Content-Type", "application/json") + 178 w.WriteHeader(http.StatusOK) + 179 _ = json.NewEncoder(w).Encode(map[string]interface{}{ + 180 "status": "ok", + 181 "time": time.Now().Unix(), + 182 }) + 183 } + 184 + 185 // handleAuthCheck returns whether authentication is required + 186 func (s *Server) handleAuthCheck(w http.ResponseWriter, r *http.Request) { + 187 w.Header().Set("Content-Type", "application/json") + 188 _ = json.NewEncoder(w).Encode(map[string]interface{}{ + 189 "required": s.config.AuthToken != "", + 190 }) + 191 } + 192 + 193 // handleAuthVerify verifies the provided token + 194 func (s *Server) handleAuthVerify(w http.ResponseWriter, r *http.Request) { + 195 if r.Method != http.MethodPost { + 196 w.WriteHeader(http.StatusMethodNotAllowed) + 197 return + 198 } + 199 + 200 var req struct { + 201 Token string `json:"token"` + 202 } + 203 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + 204 w.Header().Set("Content-Type", "application/json") + 205 w.WriteHeader(http.StatusBadRequest) + 206 _ = json.NewEncoder(w).Encode(map[string]interface{}{ + 207 "valid": false, + 208 "error": "invalid request", + 209 }) + 210 return + 211 } + 212 + 213 // If no token is configured, always return valid + 214 if s.config.AuthToken == "" { + 215 w.Header().Set("Content-Type", "application/json") + 216 _ = json.NewEncoder(w).Encode(map[string]interface{}{ + 217 "valid": true, + 218 }) + 219 return + 220 } + +exec +/bin/zsh -lc "rg -n \"type Event struct|TypeToolStart|TypeToolEnd|TypeError|tool_end|tool_start|buildStructuredEvents\" /Users/chaoyuepan/ai/imclaw/internal -g '*.go'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: +/Users/chaoyuepan/ai/imclaw/internal/session/session.go:174: case agent.TypeError: +/Users/chaoyuepan/ai/imclaw/internal/session/session_test.go:21: updated, ok = mgr.RecordEvent("cli", sess.ID, "req-1", agent.Event{Type: agent.TypeToolStart, Name: "Read"}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:108: for _, evt := range buildStructuredEvents(parser, chunk) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:137: events := buildStructuredEvents(parser, chunk) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:149: events := buildStructuredEvents(parser, agent.StreamChunk{Type: "content", Content: "[thinking] hello\nworld\n"}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:178: events := buildStructuredEvents(parser, agent.StreamChunk{Type: "error", Content: "exit status 5"}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server_test.go:182: if events[0].Type != agent.TypeError || events[0].Content != "exit status 5" { +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:12: TypeToolStart Type = "tool_start" // Tool execution started: "ToolName (pending)" +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:14: TypeToolEnd Type = "tool_end" // Tool execution completed: "ToolName (completed)" +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:21: TypeError Type = "error" // Stream or agent error +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:25:type Event struct { +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:29: Input string `json:"input,omitempty"` // Tool input for tool_input/tool_end +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:30: Output string `json:"output,omitempty"` // Tool output for tool_end +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:35: return e.Type == TypeToolStart || e.Type == TypeToolInput || e.Type == TypeToolEnd || e.Type == TypeToolError +/Users/chaoyuepan/ai/imclaw/internal/event/event.go:40: return e.Type == TypeError || e.Type == TypeToolError +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:11:// It recognizes fine-grained tool lifecycle events like tool_start and tool_end. +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:71: Type: TypeToolEnd, +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:112: Type: TypeToolEnd, +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:168: // Collect tool input lines (indented after tool_start) +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:212: return []Event{{Type: TypeToolStart, Content: content}} +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:222: Type: TypeToolStart, +/Users/chaoyuepan/ai/imclaw/internal/event/parser.go:227: // Emit tool_end after collecting indented output lines +/Users/chaoyuepan/ai/imclaw/internal/agent/agent_test.go:82: if chunks[1].Events[2].Type != TypeError { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent_test.go:127: TypeToolStart, +/Users/chaoyuepan/ai/imclaw/internal/agent/agent_test.go:130: TypeToolEnd, +/Users/chaoyuepan/ai/imclaw/internal/agent/agent_test.go:146: t.Fatalf("unexpected tool_end payload: %#v", events[6]) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:968: case agent.TypeToolStart: +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:974: case agent.TypeToolEnd: +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:984: case agent.TypeError: +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1005: for _, evt := range buildStructuredEvents(parser, chunk) { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1006: if evt.Type == agent.TypeError { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1027: if evt.Type == agent.TypeError { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1211:func buildStructuredEvents(parser *event.Parser, chunk agent.StreamChunk) []agent.Event { +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1221: events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeError, Content: chunk.Content}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1244: case event.TypeToolStart: +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1245: events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeToolStart, Name: evt.Name}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1248: case event.TypeToolEnd: +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1255: events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeToolEnd, Name: evt.Name, Input: evt.Input, Output: evt.Output}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1256: case event.TypeToolError, event.TypeError: +/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1257: events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeError, Content: evt.Content, Name: evt.Name, Input: evt.Input, Output: evt.Output}) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:40: TypeToolStart EventType = "tool_start" +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:43: TypeToolEnd EventType = "tool_end" +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:46: TypeError EventType = "error" +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:51:type Event struct { +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:666: events := append(flushEvents, Event{Version: EventProtocolVersion, Type: TypeError, Content: message}) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:852: return append(events, Event{Version: EventProtocolVersion, Type: TypeToolStart, Name: p.toolName}) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:872: Type: TypeError, +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:883: return append(events, Event{Version: EventProtocolVersion, Type: TypeToolStart, Name: content}) +/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:921: Type: TypeToolEnd, +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub.go:21: // Event holds a structured agent event (tool_start, output_final, etc.). +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:21: // Should emit: tool_start, tool_end, thinking +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:26: case TypeToolStart: +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:31: case TypeToolEnd: +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:42: t.Error("missing tool_start event") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:45: t.Error("missing tool_end event") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:64: // Should emit: tool_start, tool_error +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:68: case TypeToolStart: +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:82: t.Error("missing tool_start event") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:107: // Should have: thinking, output, tool_start, tool_end, output +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:115: case TypeToolStart: +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:117: case TypeToolEnd: +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:129: t.Error("missing tool_start event") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:132: t.Error("missing tool_end event") +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:141: if len(events) != 1 || events[0].Type != TypeToolStart { +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:142: t.Fatalf("expected tool_start after first chunk, got %#v", events) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:158: // Feed a newline to trigger tool_end +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:163: // Find tool_end event +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:166: if e.Type == TypeToolEnd { +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:174: t.Errorf("expected tool_end event, got %#v", events) +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:242: {Event{Type: TypeToolStart}, true}, +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:243: {Event{Type: TypeToolEnd}, true}, +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:248: {Event{Type: TypeError}, false}, +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:263: {Event{Type: TypeError}, true}, +/Users/chaoyuepan/ai/imclaw/internal/event/parser_test.go:265: {Event{Type: TypeToolEnd}, false}, +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:49: evt := HubEvent{Event: agent.Event{Type: agent.TypeToolStart, Name: "Read"}} +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:433: hub.Publish("sess-1", HubEvent{Event: agent.Event{Type: agent.TypeToolStart, Name: "Bash"}}) +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:447: if got.Event.Type != agent.TypeToolStart { +/Users/chaoyuepan/ai/imclaw/internal/gateway/stream_hub_test.go:575: events := buildStructuredEvents(parser, chunk) + +exec +/bin/zsh -lc "nl -ba /Users/chaoyuepan/ai/imclaw/internal/gateway/server.go | sed -n '1188,1275p'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: + 1188 } + 1189 } + 1190 + 1191 // filterTranscriptMarkers removes transcript marker lines like [thinking], [tool], [done], [acpx], [client] + 1192 func filterTranscriptMarkers(content string) string { + 1193 lines := strings.Split(content, "\n") + 1194 var filtered []string + 1195 for _, line := range lines { + 1196 trimmed := strings.TrimSpace(line) + 1197 // Skip marker lines + 1198 if trimmed == "[thinking]" || + 1199 strings.HasPrefix(trimmed, "[thinking] ") || + 1200 strings.HasPrefix(trimmed, "[tool]") || + 1201 strings.HasPrefix(trimmed, "[done]") || + 1202 strings.HasPrefix(trimmed, "[acpx]") || + 1203 strings.HasPrefix(trimmed, "[client]") { + 1204 continue + 1205 } + 1206 filtered = append(filtered, line) + 1207 } + 1208 return strings.TrimSpace(strings.Join(filtered, "\n")) + 1209 } + 1210 + 1211 func buildStructuredEvents(parser *event.Parser, chunk agent.StreamChunk) []agent.Event { + 1212 if len(chunk.Events) > 0 { + 1213 return append([]agent.Event(nil), chunk.Events...) + 1214 } + 1215 + 1216 var events []agent.Event + 1217 if chunk.Type == "content" { + 1218 events = append(events, convertLegacyEvents(parser.Feed(chunk.Content))...) + 1219 } + 1220 if chunk.Type == "error" { + 1221 events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeError, Content: chunk.Content}) + 1222 } + 1223 return events + 1224 } + 1225 + 1226 func flushStructuredEvents(parser *event.Parser, includeDone bool) []agent.Event { + 1227 events := convertLegacyEvents(parser.Flush()) + 1228 if includeDone { + 1229 events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeDone}) + 1230 } + 1231 return events + 1232 } + 1233 + 1234 func convertLegacyEvents(legacy []event.Event) []agent.Event { + 1235 var events []agent.Event + 1236 for _, evt := range legacy { + 1237 switch evt.Type { + 1238 case event.TypeThinking: + 1239 events = append(events, + 1240 agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeThinkingStart}, + 1241 agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeThinkingDelta, Content: evt.Content}, + 1242 agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeThinkingEnd, Content: evt.Content}, + 1243 ) + 1244 case event.TypeToolStart: + 1245 events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeToolStart, Name: evt.Name}) + 1246 case event.TypeToolInput: + 1247 events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeToolInput, Name: evt.Name, Input: evt.Input}) + 1248 case event.TypeToolEnd: + 1249 if evt.Input != "" { + 1250 events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeToolInput, Name: evt.Name, Input: evt.Input}) + 1251 } + 1252 if evt.Output != "" { + 1253 events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeToolOutput, Name: evt.Name, Output: evt.Output}) + 1254 } + 1255 events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeToolEnd, Name: evt.Name, Input: evt.Input, Output: evt.Output}) + 1256 case event.TypeToolError, event.TypeError: + 1257 events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeError, Content: evt.Content, Name: evt.Name, Input: evt.Input, Output: evt.Output}) + 1258 case event.TypeOutput: + 1259 // Filter transcript markers from output_final content + 1260 content := filterTranscriptMarkers(evt.Content) + 1261 events = append(events, agent.Event{Version: agent.EventProtocolVersion, Type: agent.TypeOutputFinal, Content: content}) + 1262 } + 1263 } + 1264 return events + 1265 } + 1266 + 1267 func newEventNotification(sessionID, id string, evt agent.Event) JSONRPCRequest { + 1268 params := map[string]interface{}{ + 1269 "id": id, + 1270 "session_id": sessionID, + 1271 "version": evt.Version, + 1272 "type": string(evt.Type), + 1273 "content": evt.Content, + 1274 } + 1275 if evt.Name != "" { + +exec +/bin/zsh -lc "rg -n \"func \\(s \\*Server\\) handleJobSubmit|jobMgr\\.Submit\\(|JobSubmitted|JobCompleted|JobFailed|JobDuration\" /Users/chaoyuepan/ai/imclaw/internal/gateway/server.go && nl -ba /Users/chaoyuepan/ai/imclaw/internal/gateway/server.go | sed -n '460,740p'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: +474: submittedJob := s.jobMgr.Submit(req.Prompt, req.AgentName, timeout) +475: metrics.Default().Counter(metrics.JobSubmitted).Inc() +563: metrics.Default().Counter(metrics.JobFailed).Inc() +572: metrics.Default().Latency(metrics.JobDuration).Since(execStart) +573: metrics.Default().Counter(metrics.JobCompleted).Inc() +1586:func (s *Server) handleJobSubmit(connID string, req *JSONRPCRequest) *JSONRPCResponse { +1606: submittedJob := s.jobMgr.Submit(prompt, agentName, timeout) + 460 return + 461 } + 462 if req.Prompt == "" { + 463 w.WriteHeader(http.StatusBadRequest) + 464 _ = json.NewEncoder(w).Encode(map[string]interface{}{"error": "prompt is required"}) + 465 return + 466 } + 467 if req.AgentName == "" { + 468 req.AgentName = "acpx" + 469 } + 470 + 471 // Convert timeout from seconds to duration + 472 timeout := time.Duration(req.Timeout) * time.Second + 473 + 474 submittedJob := s.jobMgr.Submit(req.Prompt, req.AgentName, timeout) + 475 metrics.Default().Counter(metrics.JobSubmitted).Inc() + 476 + 477 // Start executing the job in background + 478 go job.ExecuteJob(context.Background(), s.jobMgr, submittedJob.ID, s.executeJobPrompt) + 479 + 480 w.Header().Set("Content-Type", "application/json") + 481 w.WriteHeader(http.StatusCreated) + 482 _ = json.NewEncoder(w).Encode(submittedJob) + 483 default: + 484 w.WriteHeader(http.StatusMethodNotAllowed) + 485 } + 486 } + 487 + 488 func (s *Server) handleJobDetailAPI(w http.ResponseWriter, r *http.Request) { + 489 jobID := strings.TrimPrefix(r.URL.Path, "/api/jobs/") + 490 if jobID == "" { + 491 http.NotFound(w, r) + 492 return + 493 } + 494 + 495 switch r.Method { + 496 case http.MethodGet: + 497 job, ok := s.jobMgr.Get(jobID) + 498 if !ok { + 499 http.NotFound(w, r) + 500 return + 501 } + 502 w.Header().Set("Content-Type", "application/json") + 503 _ = json.NewEncoder(w).Encode(job) + 504 case http.MethodDelete: + 505 if err := s.jobMgr.Delete(jobID); err != nil { + 506 w.WriteHeader(http.StatusNotFound) + 507 _ = json.NewEncoder(w).Encode(map[string]interface{}{"error": err.Error()}) + 508 return + 509 } + 510 w.Header().Set("Content-Type", "application/json") + 511 _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) + 512 case http.MethodPost: + 513 // Handle job actions (cancel, retry) + 514 var req struct { + 515 Action string `json:"action"` + 516 } + 517 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + 518 w.WriteHeader(http.StatusBadRequest) + 519 return + 520 } + 521 + 522 switch req.Action { + 523 case "cancel": + 524 if err := s.jobMgr.Cancel(jobID); err != nil { + 525 w.WriteHeader(http.StatusBadRequest) + 526 _ = json.NewEncoder(w).Encode(map[string]interface{}{"error": err.Error()}) + 527 return + 528 } + 529 w.Header().Set("Content-Type", "application/json") + 530 _ = json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) + 531 default: + 532 w.WriteHeader(http.StatusBadRequest) + 533 _ = json.NewEncoder(w).Encode(map[string]interface{}{"error": "unknown action"}) + 534 } + 535 default: + 536 w.WriteHeader(http.StatusMethodNotAllowed) + 537 } + 538 } + 539 + 540 // executeJobPrompt executes a job prompt using the agent manager + 541 func (s *Server) executeJobPrompt(ctx context.Context, prompt string, logFn func(level, msg string)) (string, error) { + 542 execStart := time.Now() + 543 + 544 // Create a temporary session for this job + 545 agentType := "acpx" + 546 ag := s.agentMgr.GetOrCreate(agentType) + 547 + 548 // Create a unique session ID for this job + 549 sessionID := uuid.NewString() + 550 agentSessionID, err := ag.EnsureSession(ctx, sessionID) + 551 if err != nil { + 552 return "", fmt.Errorf("failed to create agent session: %w", err) + 553 } + 554 + 555 logFn("info", fmt.Sprintf("Started execution with agent: %s", agentType)) + 556 + 557 // Execute the prompt + 558 response, err := ag.PromptWithOptions(ctx, agentSessionID, prompt, &agent.PromptOptions{ + 559 NonInteractivePerms: "allow", + 560 }) + 561 if err != nil { + 562 logFn("error", fmt.Sprintf("Execution failed: %v", err)) + 563 metrics.Default().Counter(metrics.JobFailed).Inc() + 564 metrics.Default().Counter(metrics.AgentExecFailures).Inc() + 565 metrics.LogEvent("job.failed", sessionID, "", map[string]interface{}{ + 566 "agent": agentType, + 567 "error": metrics.Truncate(err.Error(), 200), + 568 }) + 569 return "", err + 570 } + 571 + 572 metrics.Default().Latency(metrics.JobDuration).Since(execStart) + 573 metrics.Default().Counter(metrics.JobCompleted).Inc() + 574 metrics.Default().Counter(metrics.OutputSize).Add(int64(len(response))) + 575 + 576 logFn("info", "Execution completed successfully") + 577 return response, nil + 578 } + 579 + 580 func (s *Server) handleJSONRPC(w http.ResponseWriter, r *http.Request) { + 581 if s.config.AuthToken != "" && !s.authenticateHTTP(r) { + 582 http.Error(w, "Unauthorized", http.StatusUnauthorized) + 583 return + 584 } + 585 if r.Method != http.MethodPost { + 586 w.WriteHeader(http.StatusMethodNotAllowed) + 587 return + 588 } + 589 + 590 var req JSONRPCRequest + 591 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + 592 w.Header().Set("Content-Type", "application/json") + 593 _ = json.NewEncoder(w).Encode(JSONRPCResponse{ + 594 JSONRPC: "2.0", + 595 Error: &JSONRPCError{Code: -32700, Message: "Parse error"}, + 596 }) + 597 return + 598 } + 599 + 600 start := time.Now() + 601 resp := s.handleRPCRequest("", &req) + 602 + 603 metrics.Default().Counter(metrics.RequestTotal).Inc() + 604 metrics.Default().Latency(metrics.RequestLatency).Since(start) + 605 if resp.Error != nil { + 606 metrics.Default().Counter(metrics.RequestErrors).Inc() + 607 } + 608 + 609 w.Header().Set("Content-Type", "application/json") + 610 _ = json.NewEncoder(w).Encode(resp) + 611 } + 612 + 613 func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + 614 if s.config.AuthToken != "" && !s.authenticateWS(r) { + 615 http.Error(w, "Unauthorized", http.StatusUnauthorized) + 616 return + 617 } + 618 + 619 s.connectionsMu.RLock() + 620 if len(s.connections) >= maxWSConnections { + 621 s.connectionsMu.RUnlock() + 622 http.Error(w, "Too many connections", http.StatusServiceUnavailable) + 623 return + 624 } + 625 s.connectionsMu.RUnlock() + 626 + 627 conn, err := upgrader.Upgrade(w, r, nil) + 628 if err != nil { + 629 return + 630 } + 631 + 632 ctx, cancel := context.WithCancel(context.Background()) + 633 wsConn := &WSConnection{Conn: conn, ID: uuid.NewString(), ctx: ctx, cancel: cancel} + 634 s.connectionsMu.Lock() + 635 s.connections[wsConn.ID] = wsConn + 636 s.connectionsMu.Unlock() + 637 metrics.Default().Gauge(metrics.WSConnections).Inc() + 638 + 639 _ = wsConn.SendJSON(JSONRPCRequest{ + 640 JSONRPC: "2.0", + 641 Method: "connected", + 642 Params: map[string]interface{}{ + 643 "session_id": wsConn.ID, + 644 "server_time": time.Now().UTC(), + 645 }, + 646 }) + 647 _ = wsConn.SendJSON(JSONRPCRequest{ + 648 JSONRPC: "2.0", + 649 Method: "session.snapshot", + 650 Params: map[string]interface{}{ + 651 "sessions": s.sessionMgr.Summaries(), + 652 }, + 653 }) + 654 + 655 go s.handleWSMessages(wsConn) + 656 } + 657 + 658 func (s *Server) authenticateHTTP(r *http.Request) bool { + 659 auth := r.Header.Get("Authorization") + 660 if len(auth) > 7 && auth[:7] == "Bearer " { + 661 return subtle.ConstantTimeCompare([]byte(auth[7:]), []byte(s.config.AuthToken)) == 1 + 662 } + 663 return false + 664 } + 665 + 666 func (s *Server) authenticateWS(r *http.Request) bool { + 667 token := r.URL.Query().Get("token") + 668 if token == "" { + 669 auth := r.Header.Get("Authorization") + 670 if len(auth) > 7 && auth[:7] == "Bearer " { + 671 token = auth[7:] + 672 } + 673 } + 674 if token == "" { + 675 return false + 676 } + 677 return subtle.ConstantTimeCompare([]byte(token), []byte(s.config.AuthToken)) == 1 + 678 } + 679 + 680 func (s *Server) handleWSMessages(conn *WSConnection) { + 681 defer func() { + 682 s.hub.UnsubscribeAll(conn.ID) + 683 conn.cancel() + 684 _ = conn.Close() + 685 conn.streamWG.Wait() + 686 s.connectionsMu.Lock() + 687 delete(s.connections, conn.ID) + 688 s.connectionsMu.Unlock() + 689 metrics.Default().Gauge(metrics.WSConnections).Dec() + 690 }() + 691 + 692 _ = conn.SetReadDeadline(time.Now().Add(wsPongWait)) + 693 conn.SetPongHandler(func(string) error { + 694 return conn.SetReadDeadline(time.Now().Add(wsPongWait)) + 695 }) + 696 + 697 go s.writePingLoop(conn) + 698 + 699 for { + 700 _, data, err := conn.ReadMessage() + 701 if err != nil { + 702 break + 703 } + 704 + 705 var req JSONRPCRequest + 706 if err := json.Unmarshal(data, &req); err != nil { + 707 _ = conn.SendJSON(JSONRPCResponse{ + 708 JSONRPC: "2.0", + 709 Error: &JSONRPCError{Code: -32700, Message: "Parse error"}, + 710 }) + 711 continue + 712 } + 713 + 714 if req.Method == "ask_stream" { + 715 conn.streamWG.Add(1) + 716 go func(streamReq JSONRPCRequest) { + 717 defer conn.streamWG.Done() + 718 s.handleAskStream(conn, &streamReq) + 719 }(req) + 720 continue + 721 } + 722 + 723 if req.Method == "session.subscribe" { + 724 s.handleWSSubscribe(conn, &req) + 725 continue + 726 } + 727 + 728 _ = conn.SendJSON(s.handleRPCRequest(conn.ID, &req)) + 729 } + 730 } + 731 + 732 func (s *Server) handleRPCRequest(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 733 switch req.Method { + 734 case "ask": + 735 return s.handleAsk(connID, req) + 736 case "ask_stream": + 737 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &JSONRPCError{Code: -32601, Message: "ask_stream requires WebSocket connection"}} + 738 case "session.init": + 739 return s.handleSessionInit(connID, req) + 740 case "session.new": + +exec +/bin/zsh -lc "nl -ba /Users/chaoyuepan/ai/imclaw/internal/gateway/server.go | sed -n '1568,1665p'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: + 1568 dataStr := getStringParam(params, "data") + 1569 if dataStr == "" { + 1570 return missingParam(req.ID, "data") + 1571 } + 1572 sess, err := session.ImportSession([]byte(dataStr)) + 1573 if err != nil { + 1574 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &JSONRPCError{Code: -32603, Message: fmt.Sprintf("Import failed: %v", err)}} + 1575 } + 1576 s.sessionMgr.Update(sess) + 1577 s.broadcastSession(sess) + 1578 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: sess} + 1579 } + 1580 + 1581 func (s *Server) handleAgentsList(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 1582 _ = connID + 1583 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"agents": s.agentMgr.List()}} + 1584 } + 1585 + 1586 func (s *Server) handleJobSubmit(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 1587 _ = connID + 1588 params, ok := req.Params.(map[string]interface{}) + 1589 if !ok { + 1590 return invalidParams(req.ID) + 1591 } + 1592 + 1593 prompt := getStringParam(params, "prompt") + 1594 agentName := getStringParam(params, "agent") + 1595 timeoutSeconds := getIntParam(params, "timeout") + 1596 if prompt == "" { + 1597 return missingParam(req.ID, "prompt") + 1598 } + 1599 if agentName == "" { + 1600 agentName = "acpx" + 1601 } + 1602 + 1603 // Convert timeout from seconds to duration (0 means no timeout) + 1604 timeout := time.Duration(timeoutSeconds) * time.Second + 1605 + 1606 submittedJob := s.jobMgr.Submit(prompt, agentName, timeout) + 1607 + 1608 // Start executing the job in background + 1609 go job.ExecuteJob(context.Background(), s.jobMgr, submittedJob.ID, s.executeJobPrompt) + 1610 + 1611 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: submittedJob} + 1612 } + 1613 + 1614 func (s *Server) handleJobGet(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 1615 _ = connID + 1616 params, ok := req.Params.(map[string]interface{}) + 1617 if !ok { + 1618 return invalidParams(req.ID) + 1619 } + 1620 + 1621 jobID := getStringParam(params, "job_id") + 1622 if jobID == "" { + 1623 return missingParam(req.ID, "job_id") + 1624 } + 1625 + 1626 job, ok := s.jobMgr.Get(jobID) + 1627 if !ok { + 1628 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: nil} + 1629 } + 1630 + 1631 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: job} + 1632 } + 1633 + 1634 func (s *Server) handleJobList(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 1635 _ = connID + 1636 summaries := s.jobMgr.Summaries() + 1637 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: map[string]interface{}{"jobs": summaries}} + 1638 } + 1639 + 1640 func (s *Server) handleJobCancel(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 1641 _ = connID + 1642 params, ok := req.Params.(map[string]interface{}) + 1643 if !ok { + 1644 return invalidParams(req.ID) + 1645 } + 1646 + 1647 jobID := getStringParam(params, "job_id") + 1648 if jobID == "" { + 1649 return missingParam(req.ID, "job_id") + 1650 } + 1651 + 1652 if err := s.jobMgr.Cancel(jobID); err != nil { + 1653 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Error: &JSONRPCError{Code: -32603, Message: err.Error()}} + 1654 } + 1655 + 1656 job, _ := s.jobMgr.Get(jobID) + 1657 return &JSONRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: job} + 1658 } + 1659 + 1660 func (s *Server) handleJobDelete(connID string, req *JSONRPCRequest) *JSONRPCResponse { + 1661 _ = connID + 1662 params, ok := req.Params.(map[string]interface{}) + 1663 if !ok { + 1664 return invalidParams(req.ID) + 1665 } + +exec +/bin/zsh -lc "cd /Users/chaoyuepan/ai/imclaw && go test ./internal/metrics -run 'Test(Truncate|LatencyTrackerRingBuffer|CounterAddNegative)"'$'"'" in /Users/chaoyuepan/ai/imclaw +exec +/bin/zsh -lc "cd /Users/chaoyuepan/ai/imclaw && go test ./internal/gateway -run 'Test(BuildStructuredEvents|FlushStructuredEvents|FilterTranscriptMarkers|ResolveSessionID)'" in /Users/chaoyuepan/ai/imclaw + succeeded in 882ms: +ok github.com/smallnest/imclaw/internal/metrics 0.224s + + succeeded in 1041ms: +ok github.com/smallnest/imclaw/internal/gateway 0.273s + +codex +**审核结论(Issue #11)** + +### 主要问题(按严重度) +1. **High: `session.created` 指标语义错误,且 `session.active_count` 在创建路径上不完整** + - 在 [`recordPrompt` 里每次提问都 `Inc(session.created)`](/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1102),这会把“会话创建次数”变成“prompt 次数”。 + - 真正创建会话的路径(如 `session.new` / `session.init` / `prepareSession` 首次 `GetOrCreate`)没有对应 `session.created` 计数和 `session.active_count` 更新。 + - 影响:session 维度 dashboard 会系统性失真。 + +2. **Medium: `agent.exec_failures` 在流式启动失败场景会重复计数** + - 流启动失败时,先在 agent 层加一次 [`doPromptStream` 里 `Inc(agent.exec_failures)`](/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:490)。 + - 返回 gateway 后又通过 [`rpcAgentError -> recordError` 再加一次](/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1092) / [`recordError` 内 `Inc(agent.exec_failures)`](/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1132)。 + - 影响:失败率被放大,告警阈值和趋势判断不可靠。 + +3. **Medium: `job.submitted` 只覆盖 HTTP API,未覆盖 JSON-RPC 提交路径** + - HTTP 提交有计数 [`handleJobsAPI` 中 `JobSubmitted`++ ](/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:475)。 + - JSON-RPC `job.submit` 路径缺失同样计数([`handleJobSubmit`](/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1586))。 + - 影响:同一业务指标在不同入口下不一致,统计口径不统一。 + +### 评分 +- **78 / 100** +- 优点:整体埋点面覆盖较全,metrics 抽象清晰,基础单测完善。 +- 扣分点:核心指标语义与口径一致性存在明显偏差(尤其 session 与 failure 计数)。 + +### 改进建议 +1. 把 `session.created` 与 `session.active_count` 更新移到“真实创建/删除”路径(`Create/GetOrCreate` 首次创建、Delete、Import 等),不要放在 `recordPrompt`。 +2. 统一 `agent.exec_failures` 归属层级(推荐只在 gateway 统一记一次,或仅 agent 记一次),避免双计数。 +3. 在 `handleJobSubmit`(JSON-RPC)补齐 `job.submitted` 计数,确保 HTTP/RPC 口径一致。 +4. 增加 gateway 级指标语义测试(至少覆盖以上三条),避免回归。 + +### 验证说明 +- 已运行:`go test ./internal/metrics`、`go test ./internal/gateway -run 'Test(BuildStructuredEvents|FlushStructuredEvents|FilterTranscriptMarkers|ResolveSessionID)'`。 +- 全量 `./internal/gateway` 在当前沙箱里有 `httptest` 监听端口限制,无法完整跑通。 +tokens used +82,319 +**审核结论(Issue #11)** + +### 主要问题(按严重度) +1. **High: `session.created` 指标语义错误,且 `session.active_count` 在创建路径上不完整** + - 在 [`recordPrompt` 里每次提问都 `Inc(session.created)`](/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1102),这会把“会话创建次数”变成“prompt 次数”。 + - 真正创建会话的路径(如 `session.new` / `session.init` / `prepareSession` 首次 `GetOrCreate`)没有对应 `session.created` 计数和 `session.active_count` 更新。 + - 影响:session 维度 dashboard 会系统性失真。 + +2. **Medium: `agent.exec_failures` 在流式启动失败场景会重复计数** + - 流启动失败时,先在 agent 层加一次 [`doPromptStream` 里 `Inc(agent.exec_failures)`](/Users/chaoyuepan/ai/imclaw/internal/agent/agent.go:490)。 + - 返回 gateway 后又通过 [`rpcAgentError -> recordError` 再加一次](/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1092) / [`recordError` 内 `Inc(agent.exec_failures)`](/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1132)。 + - 影响:失败率被放大,告警阈值和趋势判断不可靠。 + +3. **Medium: `job.submitted` 只覆盖 HTTP API,未覆盖 JSON-RPC 提交路径** + - HTTP 提交有计数 [`handleJobsAPI` 中 `JobSubmitted`++ ](/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:475)。 + - JSON-RPC `job.submit` 路径缺失同样计数([`handleJobSubmit`](/Users/chaoyuepan/ai/imclaw/internal/gateway/server.go:1586))。 + - 影响:同一业务指标在不同入口下不一致,统计口径不统一。 + +### 评分 +- **78 / 100** +- 优点:整体埋点面覆盖较全,metrics 抽象清晰,基础单测完善。 +- 扣分点:核心指标语义与口径一致性存在明显偏差(尤其 session 与 failure 计数)。 + +### 改进建议 +1. 把 `session.created` 与 `session.active_count` 更新移到“真实创建/删除”路径(`Create/GetOrCreate` 首次创建、Delete、Import 等),不要放在 `recordPrompt`。 +2. 统一 `agent.exec_failures` 归属层级(推荐只在 gateway 统一记一次,或仅 agent 记一次),避免双计数。 +3. 在 `handleJobSubmit`(JSON-RPC)补齐 `job.submitted` 计数,确保 HTTP/RPC 口径一致。 +4. 增加 gateway 级指标语义测试(至少覆盖以上三条),避免回归。 + +### 验证说明 +- 已运行:`go test ./internal/metrics`、`go test ./internal/gateway -run 'Test(BuildStructuredEvents|FlushStructuredEvents|FilterTranscriptMarkers|ResolveSessionID)'`。 +- 全量 `./internal/gateway` 在当前沙箱里有 `httptest` 监听端口限制,无法完整跑通。 diff --git a/.autoresearch/workflows/issue-11/iteration-4-opencode-review.log b/.autoresearch/workflows/issue-11/iteration-4-opencode-review.log new file mode 100644 index 0000000..01b8ad2 --- /dev/null +++ b/.autoresearch/workflows/issue-11/iteration-4-opencode-review.log @@ -0,0 +1 @@ +Error: API key not valid. Please pass a valid API key. diff --git a/.autoresearch/workflows/issue-11/iteration-5-codex-review.log b/.autoresearch/workflows/issue-11/iteration-5-codex-review.log new file mode 100644 index 0000000..d872ae2 --- /dev/null +++ b/.autoresearch/workflows/issue-11/iteration-5-codex-review.log @@ -0,0 +1,5414 @@ +OpenAI Codex v0.121.0 (research preview) +-------- +workdir: /Users/chaoyuepan/ai/imclaw +model: gpt-5.3-codex +provider: openai +approval: never +sandbox: workspace-write [workdir, /tmp, $TMPDIR, /Users/chaoyuepan/.codex/memories] +reasoning effort: medium +reasoning summaries: none +session id: 019d9fbb-0031-76b0-8136-03c87956d439 +-------- +user +审核 Issue #11 的实现 + +项目路径: /Users/chaoyuepan/ai/imclaw +项目语言: go +Issue 标题: feat: add observability for sessions, tools, and agent execution + +--- +请审核代码并给出评分和改进建议: +# Codex Agent + +你是一个专业的软件工程师 Agent,既能实现功能,也能审核代码。 + +--- + +## 角色定位 + +**你可以是实现者,也可以是审核者,取决于任务要求。** + +- 作为实现者:根据 Issue 描述或审核反馈实现/改进代码 +- 作为审核者:审查代码质量,给出评分和改进建议 +- 你需要编写代码和测试 +- 你需要接受审核反馈并改进 + +--- + +## 工作流程 + +### Phase 1: 理解需求 + +``` +1. 阅读 Issue #N 的完整内容 +2. 理解 Issue 的核心诉求 +3. 如果有疑问,列出需要澄清的问题 +4. 确认涉及的代码模块 +``` + +### Phase 2: 分析代码 + +``` +1. 使用 Glob 和 Grep 工具搜索相关代码 +2. 阅读相关文件,理解现有架构 +3. 识别需要修改的文件 +4. 评估改动范围和影响 +``` + +### Phase 3: 实现代码 + +``` +1. 编写功能实现代码 +2. 编写单元测试代码 +3. 确保测试覆盖核心逻辑 +4. 运行测试验证实现 +``` + +--- + +## 输出格式 + +每次实现完成后,你必须输出以下结构: + +```markdown +## 实现报告 + +### Issue +- 编号: #N +- 标题: [Issue 标题] +- 类型: feature / bugfix / refactor / docs + +### 改动概述 +- 修改文件: [文件列表] +- 新增文件: [文件列表] +- 删除文件: [文件列表] +- 代码行数: +X / -Y + +### 实现思路 +[描述你的实现思路,关键设计决策] + +### 测试情况 +- 测试文件: [测试文件路径] +- 测试用例数: N +- 覆盖场景: + - [场景1] + - [场景2] + - ... + +### 待确认问题 +- [列出需要审核者关注的问题,如果没有则写"无"] +``` + +--- + +## 代码规范 + +### Go 代码规范 + +```go +// 1. 包注释 +// Package parser 提供事件流解析功能。 +package parser + +// 2. 函数注释 +// Parse 解析输入数据并返回事件列表。 +// 如果输入格式无效,返回 ErrInvalidInput 错误。 +func Parse(input []byte) ([]Event, error) { ... } + +// 3. 错误处理 +if err != nil { + return fmt.Errorf("parse failed: %w", err) +} + +// 4. 表格驱动测试 +func TestParse(t *testing.T) { + tests := []struct { + name string + input string + want []Event + wantErr bool + }{ + {"valid input", `{"type":"start"}`, []Event{{Type: "start"}}, false}, + {"invalid json", `{invalid}`, nil, true}, + {"empty input", ``, nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Parse([]byte(tt.input)) + if (err != nil) != tt.wantErr { + t.Errorf("Parse() error = %v, wantErr %v", err, tt.wantErr) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Parse() = %v, want %v", got, tt.want) + } + }) + } +} +``` + +### 禁止事项 + +``` +❌ 不要使用 panic(除非在不可恢复的错误场景) +❌ 不要忽略错误返回值 +❌ 不要使用全局变量 +❌ 不要在测试中使用 time.Sleep +❌ 不要硬编码配置值 +❌ 不要提交注释掉的代码 +❌ 不要使用魔法数字 +``` + +--- + +## 面对审核反馈 + +当审核者 (Claude) 提出批评意见时: + +### 1. 理解反馈 + +```markdown +## 反馈理解 + +审核者提出以下问题: +1. [问题1] - 我的理解:[描述理解] +2. [问题2] - 我的理解:[描述理解] + +我是否正确理解了反馈? +``` + +### 2. 分类处理 + +``` +| 反馈类型 | 处理方式 | +|---------|---------| +| 严重 Bug | 立即修复 | +| 设计问题 | 评估影响后修复 | +| 代码风格 | 按规范修改 | +| 性能问题 | 评估必要性后处理 | +| 建议性意见 | 根据实际情况决定是否采纳 | +``` + +### 3. 改进报告 + +```markdown +## 改进报告 + +### 已修复问题 +- [问题1]: [修复方案] +- [问题2]: [修复方案] + +### 未采纳建议 +- [建议]: [原因] + +### 新增改动 +- [描述新增的改动] +``` + +--- + +## 特殊情况处理 + +### 无法实现 + +如果 Issue 描述不清或技术上不可行: + +```markdown +## 无法实现报告 + +### Issue +- 编号: #N + +### 阻塞原因 +- [ ] 需求不明确 +- [ ] 缺少必要上下文 +- [ ] 技术限制 +- [ ] 依赖未解决 + +### 详细说明 +[描述具体原因] + +### 建议操作 +- [建议人类如何处理这个 Issue] +``` + +### 需要设计决策 + +如果 Issue 涉及架构级别的决策: + +```markdown +## 需要设计评审 + +### Issue +- 编号: #N + +### 设计问题 +[描述需要决策的问题] + +### 可选方案 +1. 方案A: [描述] - 优点: [] 缺点: [] +2. 方案B: [描述] - 优点: [] 缺点: [] + +### 建议 +[你的建议和理由] + +### 请人工确认后继续 +``` + +--- + +## 迭代限制 + +- 最大迭代次数: 5 次 +- 如果连续 3 次测试失败,报告阻塞 +- 如果审核者满意度不再提升,可以停止迭代并说明原因 + +--- + +## 示例对话 + +### 示例 1: 功能实现 + +**Human**: 实现 Issue #42 - 添加用户认证功能 + +**Codex**: +``` +我来分析 Issue #42 并实现用户认证功能。 + +首先,让我了解现有代码结构... +[使用 Glob/Grep 搜索相关代码] + +分析完成,我计划: +1. 在 internal/auth/ 目录下创建认证模块 +2. 实现 JWT token 生成和验证 +3. 添加认证中间件 +4. 编写单元测试 + +开始实现... +[编写代码] + +运行测试... +go test ./internal/auth/... -v + +## 实现报告 +[按格式输出报告] +``` + +### 示例 2: 处理审核反馈 + +**Claude**: "代码中使用了硬编码的密钥,存在安全风险..." + +**Codex**: +``` +## 反馈理解 + +审核者指出: +- 问题: 硬编码密钥存在安全风险 + +我的理解: 应该从配置或环境变量读取密钥 + +## 改进 + +修改方案: +1. 将密钥改为从环境变量读取 +2. 添加默认值用于开发环境 +3. 添加配置验证 + +[修改代码] + +## 改进报告 +[按格式输出报告] +``` + +codex +我会先定位 Issue #11 对应的实现改动(提交/分支/文件),再按“正确性、可观测性设计、测试覆盖、回归风险”做代码审核并给出评分。先快速扫一遍仓库和最近提交。 +exec +/bin/zsh -lc "rg -n \"observability|session|tool|agent execution|otel|metrics|trace|span|instrument|telemetry\" -S --glob '"'!**/vendor/**'"'" in /Users/chaoyuepan/ai/imclaw + succeeded in 0ms: +Makefile:89: $(GOCMD) tool cover -html=coverage.out -o coverage.html +cmd/imclaw-cli/main_test.go:52: *allowedTools = "" +cmd/imclaw-cli/main_test.go:53: *deniedTools = "Write" +cmd/imclaw-cli/main_test.go:59: *allowedTools = "" +cmd/imclaw-cli/main_test.go:60: *deniedTools = "" +cmd/imclaw-cli/main_test.go:70: allowed := strings.Split(policy.AllowedToolsCSV(), ",") +cmd/imclaw-cli/main_test.go:71: for _, tool := range allowed { +cmd/imclaw-cli/main_test.go:72: if tool == "Write" { +cmd/imclaw-cli/main_test.go:73: t.Fatalf("expected Write to be denied, got %q", policy.AllowedToolsCSV()) +cmd/imclaw-cli/main_test.go:80: *allowedTools = "Read,Grep" +cmd/imclaw-cli/main_test.go:81: *deniedTools = "Grep" +cmd/imclaw-cli/main_test.go:86: *allowedTools = "" +cmd/imclaw-cli/main_test.go:87: *deniedTools = "" +cmd/imclaw-cli/main_test.go:99: if got := params["allowed_tools"]; got != "Read" { +cmd/imclaw-cli/main_test.go:100: t.Fatalf("allowed_tools = %#v, want Read", got) +cmd/imclaw-cli/main_test.go:102: if got := params["denied_tools"]; got != "Grep" { +cmd/imclaw-cli/main_test.go:103: t.Fatalf("denied_tools = %#v, want Grep", got) +cmd/imclaw-cli/main_test.go:115: if !shouldSuggestApproveAll("User refused permission to run tool") { +cmd/imclaw-cli/main_test.go:162: "S": "session", +docs/feature-issue-drafts.md:16:- Define typed events such as `thinking`, `tool_start`, `tool_end`, `output_delta`, `output_final`, and `error` +docs/feature-issue-drafts.md:32:- A client can render agent execution without parsing transcript text +docs/feature-issue-drafts.md:33:- Tool execution boundaries are explicit in the stream +docs/feature-issue-drafts.md:38:## 2. Web UI for session and stream inspection +docs/feature-issue-drafts.md:41:`feat: add web UI for sessions, streams, and tool activity` +docs/feature-issue-drafts.md:44:IMClaw needs a first-class UI for observing sessions, prompts, streaming output, tool activity, and errors. This would make the gateway significantly easier to use in remote and collaborative environments. +docs/feature-issue-drafts.md:47:- Show active and historical sessions +docs/feature-issue-drafts.md:49:- Visualize `thinking`, tool execution, and final output separately +docs/feature-issue-drafts.md:50:- Allow switching agents and creating new sessions +docs/feature-issue-drafts.md:55:- Real-time subscription to session events +docs/feature-issue-drafts.md:56:- Session detail page +docs/feature-issue-drafts.md:60:- A user can inspect and follow a live session from the browser +docs/feature-issue-drafts.md:61:- A user can review previous sessions and their outputs +docs/feature-issue-drafts.md:62:- Tool execution is visible and distinguishable from final output +docs/feature-issue-drafts.md:66:## 3. Permission policy presets and tool-level controls +docs/feature-issue-drafts.md:69:`feat: add permission policy presets and tool-level controls` +docs/feature-issue-drafts.md:72:Current permission handling is useful but still too coarse for production or shared environments. IMClaw should support reusable policy presets and more explicit tool-level restrictions. +docs/feature-issue-drafts.md:76:- Allow tool-level allow/deny rules +docs/feature-issue-drafts.md:78:- Improve permission-related error messages and observability +docs/feature-issue-drafts.md:88:- Tool execution can be restricted beyond the current coarse modes +docs/feature-issue-drafts.md:93:## 4. Session lifecycle features: rename, tag, archive, export +docs/feature-issue-drafts.md:96:`feat: improve session lifecycle with rename, tag, archive, and export` +docs/feature-issue-drafts.md:99:Sessions are one of IMClaw’s strongest primitives. They should be easier to organize, recover, and reuse over time. +docs/feature-issue-drafts.md:102:- Rename sessions +docs/feature-issue-drafts.md:104:- Archive sessions without deleting them +docs/feature-issue-drafts.md:105:- Export session history to markdown or JSON +docs/feature-issue-drafts.md:106:- Re-import sessions where feasible +docs/feature-issue-drafts.md:109:- Session metadata model update +docs/feature-issue-drafts.md:115:- A session can be renamed and tagged +docs/feature-issue-drafts.md:116:- Archived sessions remain retrievable +docs/feature-issue-drafts.md:117:- A session can be exported for backup or review +docs/feature-issue-drafts.md:121:## 5. Multi-subscriber session streaming +docs/feature-issue-drafts.md:124:`feat: support multiple subscribers for the same live session` +docs/feature-issue-drafts.md:127:A single IMClaw session should be observable by multiple clients at the same time, such as CLI, web UI, and monitoring components. +docs/feature-issue-drafts.md:130:- Allow more than one client to subscribe to the same live session stream +docs/feature-issue-drafts.md:132:- Avoid coupling session lifetime to a single WebSocket connection +docs/feature-issue-drafts.md:135:- Session stream fan-out design +docs/feature-issue-drafts.md:141:- Multiple clients can observe the same session concurrently +docs/feature-issue-drafts.md:142:- One subscriber disconnecting does not terminate the session +docs/feature-issue-drafts.md:174:## 7. Observability and execution metrics +docs/feature-issue-drafts.md:177:`feat: add observability for sessions, tools, and agent execution` +docs/feature-issue-drafts.md:180:IMClaw currently lacks strong visibility into performance and failure patterns. We should add metrics and execution tracing that make tuning and debugging practical. +docs/feature-issue-drafts.md:184:- Track tool call counts and durations +docs/feature-issue-drafts.md:186:- Track output sizes and session activity +docs/feature-issue-drafts.md:189:- Metrics collection points +docs/feature-issue-drafts.md:191:- Optional metrics endpoint or exporter +docs/feature-issue-drafts.md:195:- Operators can identify slow sessions and noisy tools +docs/feature-issue-drafts.md:197:- Session activity is observable over time +docs/feature-issue-drafts.md:237:- Isolate backend-specific session and stream behavior +docs/feature-issue-drafts.md:247:- Session and stream semantics are consistent across adapters where possible +skills/acp-remote/SKILL.md:125:### Continue a session +skills/acp-remote/SKILL.md:128:# First message creates session, returns session ID +skills/acp-remote/SKILL.md:131:# Continue with same session +skills/acp-remote/SKILL.md:132:imclaw-cli --server ws://your-server:8080/ws --session -p "Summarize it" +skills/acp-remote/SKILL.md:142:| `--session` | Session ID to continue | +skills/acp-remote/SKILL.md:148:| `--allowed-tools` | Comma-separated tool names (default: Bash,Read,Write) | +skills/acp-remote/SKILL.md:209:1. **Session reuse**: Save the session ID from first response to continue conversations +skills/acp-remote/SKILL.md:212:4. **Tool restrictions**: Use `--allowed-tools` to limit what the agent can do +cmd/imclaw/imclaw.go:13: "github.com/smallnest/imclaw/internal/session" +cmd/imclaw/imclaw.go:58: // Create session manager +cmd/imclaw/imclaw.go:59: sessionMgr := session.NewManager() +cmd/imclaw/imclaw.go:76: srv := gateway.NewServer(cfg, sessionMgr, agentMgr, jobMgr) +internal/agent/agent_test.go:22: DeniedTools: "Write", +internal/agent/agent_test.go:41: if strings.Contains(joined, "--allowed-tools Bash,Edit,Glob,Grep,LS,MultiEdit,NotebookEdit,Read,TodoWrite,WebFetch,WebSearch,Write") || strings.Contains(joined, ",Write,") || strings.Contains(joined, ",Write ") || strings.Contains(joined, " Write,") { +internal/agent/agent_test.go:42: t.Fatalf("denied tool leaked into args: %q", joined) +internal/agent/agent_test.go:116:func TestProtocolParserEmitsToolLifecycleAndTerminalEvents(t *testing.T) { +internal/agent/agent_test.go:119: events := parser.Feed("[thinking] plan\n[tool] Read (pending)\n path=/tmp\n[tool] Read (completed)\n ok\nanswer\n") +internal/agent/agent_test.go:127: TypeToolStart, +internal/agent/agent_test.go:128: TypeToolInput, +internal/agent/agent_test.go:129: TypeToolOutput, +internal/agent/agent_test.go:130: TypeToolEnd, +internal/agent/agent_test.go:146: t.Fatalf("unexpected tool_end payload: %#v", events[6]) +internal/session/session_test.go:1:package session +internal/session/session_test.go:9:func TestManagerRecordsSessionActivity(t *testing.T) { +internal/session/session_test.go:18: t.Fatalf("expected running session after prompt, got active=%v status=%q", updated.Active, updated.Status) +internal/session/session_test.go:21: updated, ok = mgr.RecordEvent("cli", sess.ID, "req-1", agent.Event{Type: agent.TypeToolStart, Name: "Read"}) +internal/session/session_test.go:34: t.Fatalf("unexpected session summary after result: %#v", updated) +internal/session/session_test.go:42: t.Fatalf("unexpected session error state: %#v", updated) +internal/session/session_test.go:51: t.Fatal("expected prompt to update session ordering") +internal/session/session_test.go:59: t.Fatalf("expected most recently active session first, got %#v", summaries) +internal/metrics/metrics_test.go:1:package metrics +internal/metrics/metrics_test.go:287: SessionCreated, SessionDeleted, SessionActive, SessionPromptLatency, +internal/metrics/metrics_test.go:289: ToolCallCount, ToolCallDuration, ToolCallErrors, +internal/transcript/parser.go:15: MessageTool MessageType = "tool" +internal/transcript/parser.go:37:// It keeps only thinking, tool, and assistant output blocks. +internal/transcript/parser.go:155: case "tool": +internal/transcript/parser.go:156: p.currentType = MessageTool +internal/transcript/parser.go:168: case MessageThinking, MessageTool: +internal/transcript/parser.go:177: // Non-indented line ends thinking/tool block, starts output +internal/transcript/parser.go:201:// parseMarker parses a line for transcript markers like [thinking], [tool], etc. +internal/transcript/parser.go:226: case "thinking", "tool", "done", "client", "acpx": +README.md:96:# 使用指定的 Session ID +README.md:97:./bin/imclaw-cli --session my-session-123 +README.md:116:# 使用指定 Session(可复用会话) +README.md:117:./bin/imclaw-cli --session my-session -p "继续之前的对话" +README.md:136:| `--session ` | 指定使用的 Session ID(为空则自动创建) | +README.md:148:| `--allowed-tools ` | 允许的工具名称(逗号分隔)。默认:Bash,Read,Write。空字符串=允许所有 | +README.md:149:| `--denied-tools ` | 拒绝的工具名称(逗号分隔),优先级高于 allowed-tools | +README.md:168:| **AllowedTools** | `--allowed-tools` | 工具白名单 | `Bash,Read,Write` | +README.md:169:| **DeniedTools** | `--denied-tools` | 工具黑名单,从白名单中剔除 | `Write,Bash` | +README.md:179:| 预设名 | Permissions | AllowedTools | 适用场景 | +README.md:219:### 3. AllowedTools(工具白名单) +README.md:241:imclaw-cli --allowed-tools Read,Grep,Glob -p "分析代码" +README.md:244:imclaw-cli --allowed-tools "" -p "任意操作" +README.md:247:imclaw-cli --allowed-tools Bash -p "运行测试" +README.md:252:### 4. DeniedTools(工具黑名单) +README.md:254:从 AllowedTools 中剔除指定工具,实现"允许大部分但排除某些"的效果。 +README.md:256:**优先级**:`DeniedTools` > `AllowedTools` +README.md:260:imclaw-cli --permission-preset full-auto --denied-tools Write -p "帮我分析代码" +README.md:263:imclaw-cli --permission-preset full-auto --denied-tools Write,Bash -p "只读分析" +README.md:266:imclaw-cli --allowed-tools Bash,Read,Write,Edit --denied-tools Write -p "可以编辑但不能新建文件" +README.md:316:3. AllowedTools → 覆盖预设的工具白名单 +README.md:317:4. DeniedTools → 从当前白名单中剔除 +README.md:328: --denied-tools Write,Bash \ # 4. 从工具列表中剔除 Write 和 Bash +README.md:333:# - AllowedTools: 所有工具 - {Write, Bash} +README.md:348:imclaw-cli --deny-all --allowed-tools Glob,Grep,LS,Read -p "审查代码" +README.md:358:imclaw-cli --approve-reads --allowed-tools Bash,Read,Write -p "实现功能" +README.md:376: --denied-tools Bash \ +README.md:424: │ AllowedTools 中 │ +README.md:453:| `/session` | 显示当前会话信息 | +internal/transcript/parser_test.go:9: raw := `[acpx] session 78f133c0-862e-4ded-a22b-069338116f20 (8b2caf0c-dfe9-40c1-b718-700de1704a88) · /Users/chaoyuepan/ai/imclaw · agent needs reconnect +internal/transcript/parser_test.go:12:[client] session/new (running) +internal/transcript/parser_test.go:19:[tool] Skill (pending) +internal/transcript/parser_test.go:22:[tool] Skill (completed) +internal/transcript/parser_test.go:50: if got[2].Type != MessageTool || got[2].Content != "Skill (pending)\n input: {}" { +internal/transcript/parser_test.go:51: t.Fatalf("unexpected pending tool message: %#v", got[2]) +internal/transcript/parser_test.go:53: if got[3].Type != MessageTool { +internal/transcript/parser_test.go:54: t.Fatalf("expected completed tool message, got %#v", got[3]) +internal/transcript/parser_test.go:80: raw := `[acpx] session abc +internal/metrics/metrics.go:1:// Package metrics provides lightweight observability for IMClaw via structured logs. +internal/metrics/metrics.go:3:// All metrics are emitted through Go's standard log package with a consistent +internal/metrics/metrics.go:4:// [metrics] prefix and structured key=value pairs, making them grep-friendly +internal/metrics/metrics.go:6:package metrics +internal/metrics/metrics.go:27: log.Printf("[metrics] counter %s=%d", c.name, v) +internal/metrics/metrics.go:34: log.Printf("[metrics] counter %s: Add called with negative value %d, ignoring", c.name, n) +internal/metrics/metrics.go:38: log.Printf("[metrics] counter %s=%d delta=%d", c.name, v, n) +internal/metrics/metrics.go:73: log.Printf("[metrics] latency %s duration_ms=%.2f", lt.name, float64(d)/float64(time.Millisecond)) +internal/metrics/metrics.go:124: log.Printf("[metrics] gauge %s=%d", g.name, v) +internal/metrics/metrics.go:130: log.Printf("[metrics] gauge %s=%d", g.name, v) +internal/metrics/metrics.go:137: log.Printf("[metrics] gauge %s=%d", g.name, v) +internal/metrics/metrics.go:148:// Registry groups named metrics for a subsystem. +internal/metrics/metrics.go:156:// NewRegistry creates a new metrics registry. +internal/metrics/metrics.go:201:// Snapshot returns a point-in-time snapshot of all metrics. +internal/metrics/metrics.go:223:// Snapshot is a point-in-time view of all registry metrics. +internal/metrics/metrics.go:234:// Default returns the global default metrics registry. +internal/metrics/metrics.go:242: // Session metrics +internal/metrics/metrics.go:243: SessionCreated = "session.created" +internal/metrics/metrics.go:244: SessionDeleted = "session.deleted" +internal/metrics/metrics.go:245: SessionActive = "session.active_count" +internal/metrics/metrics.go:246: SessionPromptLatency = "session.prompt_latency" +internal/metrics/metrics.go:248: // Request metrics +internal/metrics/metrics.go:253: // Tool metrics +internal/metrics/metrics.go:254: ToolCallCount = "tool.call_count" +internal/metrics/metrics.go:255: ToolCallDuration = "tool.call_duration" +internal/metrics/metrics.go:256: ToolCallErrors = "tool.call_errors" +internal/metrics/metrics.go:258: // Permission metrics +internal/metrics/metrics.go:261: // Agent metrics +internal/metrics/metrics.go:265: // Output metrics +internal/metrics/metrics.go:268: // Job metrics +internal/metrics/metrics.go:274: // Connection metrics +internal/metrics/metrics.go:283:func LogEvent(event, sessionID, requestID string, extra map[string]interface{}) { +internal/metrics/metrics.go:286: if sessionID != "" { +internal/metrics/metrics.go:287: pairs = append(pairs, "session_id", sessionID) +internal/metrics/metrics.go:295: log.Printf("[metrics] event "+repeatFormat(len(pairs)/2), pairs...) +internal/session/export.go:1:package session +internal/session/export.go:10:// ExportFormat represents the format for session export. +internal/session/export.go:26: Session *Session `json:"session"` +internal/session/export.go:29:// ExportSession exports a session to the specified format, returning the serialized bytes. +internal/session/export.go:30:func ExportSession(sess *Session, format ExportFormat) ([]byte, error) { +internal/session/export.go:32: return nil, fmt.Errorf("session is nil") +internal/session/export.go:45:func exportJSON(sess *Session) ([]byte, error) { +internal/session/export.go:50: Session: sess, +internal/session/export.go:55:func exportMarkdown(sess *Session) ([]byte, error) { +internal/session/export.go:58: b.WriteString(fmt.Sprintf("# Session: %s\n\n", sessionDisplayName(sess))) +internal/session/export.go:114:// ImportSession imports a session from JSON export data. +internal/session/export.go:115:func ImportSession(data []byte) (*Session, error) { +internal/session/export.go:120: if exportData.Session == nil { +internal/session/export.go:121: return nil, fmt.Errorf("export data contains no session") +internal/session/export.go:126: return exportData.Session, nil +internal/session/export.go:129:func sessionDisplayName(sess *Session) string { +internal/session/export_test.go:1:package session +internal/session/export_test.go:12:func TestExportSessionJSON(t *testing.T) { +internal/session/export_test.go:13: sess := &Session{ +internal/session/export_test.go:17: Name: "Test Session", +internal/session/export_test.go:29: data, err := ExportSession(sess, ExportJSON) +internal/session/export_test.go:41: if exportData.Session == nil { +internal/session/export_test.go:42: t.Fatal("expected session in export") +internal/session/export_test.go:44: if exportData.Session.ID != "test-1" { +internal/session/export_test.go:45: t.Fatalf("expected session ID 'test-1', got %q", exportData.Session.ID) +internal/session/export_test.go:47: if exportData.Session.Name != "Test Session" { +internal/session/export_test.go:48: t.Fatalf("expected name 'Test Session', got %q", exportData.Session.Name) +internal/session/export_test.go:50: if len(exportData.Session.Tags) != 2 { +internal/session/export_test.go:51: t.Fatalf("expected 2 tags, got %d", len(exportData.Session.Tags)) +internal/session/export_test.go:53: if len(exportData.Session.Activity) != 2 { +internal/session/export_test.go:54: t.Fatalf("expected 2 activities, got %d", len(exportData.Session.Activity)) +internal/session/export_test.go:58:func TestExportSessionMarkdown(t *testing.T) { +internal/session/export_test.go:59: sess := &Session{ +internal/session/export_test.go:63: Name: "Markdown Session", +internal/session/export_test.go:77: data, err := ExportSession(sess, ExportMarkdown) +internal/session/export_test.go:83: if !strings.Contains(md, "# Session: Markdown Session") { +internal/session/export_test.go:84: t.Fatalf("expected session name in markdown header, got:\n%s", md) +internal/session/export_test.go:103:func TestExportSessionMarkdownUsesIDWhenNoName(t *testing.T) { +internal/session/export_test.go:104: sess := &Session{ +internal/session/export_test.go:109: data, err := ExportSession(sess, ExportMarkdown) +internal/session/export_test.go:118:func TestExportSessionUnsupported(t *testing.T) { +internal/session/export_test.go:119: sess := &Session{ID: "x", Metadata: map[string]interface{}{}} +internal/session/export_test.go:120: _, err := ExportSession(sess, ExportFormat("xml")) +internal/session/export_test.go:126:func TestExportSessionNil(t *testing.T) { +internal/session/export_test.go:127: _, err := ExportSession(nil, ExportJSON) +internal/session/export_test.go:129: t.Fatal("expected error for nil session") +internal/session/export_test.go:133:func TestImportSessionFromJSON(t *testing.T) { +internal/session/export_test.go:134: // First export a session +internal/session/export_test.go:135: sess := &Session{ +internal/session/export_test.go:139: Name: "Imported Session", +internal/session/export_test.go:150: data, err := ExportSession(sess, ExportJSON) +internal/session/export_test.go:156: imported, err := ImportSession(data) +internal/session/export_test.go:163: if imported.Name != "Imported Session" { +internal/session/export_test.go:164: t.Fatalf("expected name 'Imported Session', got %q", imported.Name) +internal/session/export_test.go:174:func TestImportSessionInvalidData(t *testing.T) { +internal/session/export_test.go:175: _, err := ImportSession([]byte("not json")) +internal/session/export_test.go:181:func TestImportSessionMissingSession(t *testing.T) { +internal/session/export_test.go:183: _, err := ImportSession(data) +internal/session/export_test.go:185: t.Fatal("expected error for missing session field") +internal/session/export_test.go:190: sess := &Session{ +internal/session/export_test.go:208: data, err := ExportSession(sess, ExportJSON) +internal/session/export_test.go:213: restored, err := ImportSession(data) +internal/session/export_test.go:236: sess := &Session{ +internal/session/export_test.go:239: Name: "Event Session", +internal/session/export_test.go:249: Name: "tool_use", +internal/session/export_test.go:264: data, err := ExportSession(sess, ExportMarkdown) +internal/session/export_test.go:270: if !strings.Contains(md, "**Name**: tool_use") { +internal/session/export_test.go:287:func TestImportSessionVersionValidation(t *testing.T) { +internal/session/export_test.go:289: validData := []byte(`{"exported_at":"2025-01-01T00:00:00Z","format":"json","version":"1.0","session":{"id":"v1","metadata":{}}}`) +internal/session/export_test.go:290: _, err := ImportSession(validData) +internal/session/export_test.go:296: invalidData := []byte(`{"exported_at":"2025-01-01T00:00:00Z","format":"json","version":"2.0","session":{"id":"v2","metadata":{}}}`) +internal/session/export_test.go:297: _, err = ImportSession(invalidData) +internal/session/export_test.go:306: emptyVerData := []byte(`{"exported_at":"2025-01-01T00:00:00Z","format":"json","version":"","session":{"id":"v3","metadata":{}}}`) +internal/session/export_test.go:307: _, err = ImportSession(emptyVerData) +internal/session/session_lifecycle_test.go:1:package session +internal/session/session_lifecycle_test.go:12: updated, ok := mgr.Rename("cli", sess.ID, "My Session") +internal/session/session_lifecycle_test.go:16: if updated.Name != "My Session" { +internal/session/session_lifecycle_test.go:17: t.Fatalf("expected name 'My Session', got %q", updated.Name) +internal/session/session_lifecycle_test.go:23: t.Fatal("expected session to exist") +internal/session/session_lifecycle_test.go:25: if fetched.Name != "My Session" { +internal/session/session_lifecycle_test.go:34: t.Fatal("expected rename to fail for nonexistent session") +internal/session/session_lifecycle_test.go:105: t.Fatal("new session should not be archived") +internal/session/session_lifecycle_test.go:114: t.Fatal("expected session to be archived") +internal/session/session_lifecycle_test.go:129: t.Fatal("expected session to be unarchived") +internal/session/session_lifecycle_test.go:137: t.Fatal("expected archive to fail for nonexistent session") +internal/session/session_lifecycle_test.go:145: t.Fatal("expected unarchive to fail for nonexistent session") +internal/session/session_lifecycle_test.go:161: t.Fatalf("expected 2 sessions with 'important' tag, got %d", len(tagged)) +internal/session/session_lifecycle_test.go:166: t.Fatalf("expected 1 session with 'review' tag, got %d", len(tagged)) +internal/session/session_lifecycle_test.go:171: t.Fatalf("expected 0 sessions, got %d", len(tagged)) +internal/session/session_lifecycle_test.go:186: t.Fatalf("expected 2 archived sessions, got %d", len(archived)) +internal/session/session_lifecycle_test.go:193: mgr.Rename("cli", sess.ID, "Test Session") +internal/session/session_lifecycle_test.go:201: if summary.Name != "Test Session" { +internal/session/session_lifecycle_test.go:250: t.Fatal("expected SetTags to fail for nonexistent session") +internal/session/session_lifecycle_test.go:254:func TestCloneSessionPreservesTags(t *testing.T) { +internal/session/session_lifecycle_test.go:262: cloned := cloneSession(original) +internal/session/session_lifecycle_test.go:308: // Verify the session is still valid (no panic, no corruption) +internal/session/session_lifecycle_test.go:311: t.Fatal("expected session to exist after concurrent operations") +internal/session/session_lifecycle_test.go:314: t.Fatalf("session ID corrupted: %q", final.ID) +internal/agent/agent.go:19: "github.com/smallnest/imclaw/internal/metrics" +internal/agent/agent.go:40: TypeToolStart EventType = "tool_start" +internal/agent/agent.go:41: TypeToolInput EventType = "tool_input" +internal/agent/agent.go:42: TypeToolOutput EventType = "tool_output" +internal/agent/agent.go:43: TypeToolEnd EventType = "tool_end" +internal/agent/agent.go:68: // CreateSession creates a new agent session +internal/agent/agent.go:69: CreateSession(ctx context.Context, sessionName string) (string, error) +internal/agent/agent.go:71: // EnsureSession ensures a session exists +internal/agent/agent.go:72: EnsureSession(ctx context.Context, sessionName string) (string, error) +internal/agent/agent.go:75: Prompt(ctx context.Context, sessionID, prompt string) (string, error) +internal/agent/agent.go:78: PromptWithOptions(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (string, error) +internal/agent/agent.go:81: PromptStream(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (<-chan StreamChunk, error) +internal/agent/agent.go:113: // Allowed tools +internal/agent/agent.go:114: AllowedTools string +internal/agent/agent.go:116: // Denied tools +internal/agent/agent.go:117: DeniedTools string +internal/agent/agent.go:272:// CreateSession creates a new agent session +internal/agent/agent.go:273:func (a *ACPXAgent) CreateSession(ctx context.Context, sessionName string) (string, error) { +internal/agent/agent.go:274: args := []string{a.agentType, "sessions", "new", "--name", sessionName} +internal/agent/agent.go:275: log.Printf("[acpx] Creating session: %s", sessionName) +internal/agent/agent.go:283: log.Printf("[acpx] Session created, output: %s", output) +internal/agent/agent.go:287: SessionID string `json:"sessionId"` +internal/agent/agent.go:294: if err := parseJSON(line, &result); err == nil && result.SessionID != "" { +internal/agent/agent.go:295: log.Printf("[acpx] Parsed session ID from JSON: %s", result.SessionID) +internal/agent/agent.go:296: return result.SessionID, nil +internal/agent/agent.go:301: // Try to parse: [acpx] created session () +internal/agent/agent.go:306: if strings.Contains(line, "created session") { +internal/agent/agent.go:312: log.Printf("[acpx] Parsed session ID from created line: %s", id) +internal/agent/agent.go:321: log.Printf("[acpx] Parsed session ID from created status: %s", id) +internal/agent/agent.go:327: log.Printf("[acpx] No sessionId in output, using session name: %s", sessionName) +internal/agent/agent.go:328: return sessionName, nil +internal/agent/agent.go:331:// EnsureSession ensures a session exists +internal/agent/agent.go:332:func (a *ACPXAgent) EnsureSession(ctx context.Context, sessionName string) (string, error) { +internal/agent/agent.go:333: args := []string{a.agentType, "sessions", "ensure", "--name", sessionName} +internal/agent/agent.go:334: log.Printf("[acpx] Ensuring session: %s", sessionName) +internal/agent/agent.go:342: log.Printf("[acpx] Session ensured, output: %s", output) +internal/agent/agent.go:344: // Try to parse JSON format first: {"sessionId": "xxx"} +internal/agent/agent.go:346: SessionID string `json:"sessionId"` +internal/agent/agent.go:353: if err := parseJSON(line, &result); err == nil && result.SessionID != "" { +internal/agent/agent.go:354: log.Printf("[acpx] Parsed session ID from JSON: %s", result.SessionID) +internal/agent/agent.go:355: return result.SessionID, nil +internal/agent/agent.go:360: // Try to parse: [acpx] created session () +internal/agent/agent.go:365: // Format: [acpx] created session name (id) +internal/agent/agent.go:366: if strings.Contains(line, "created session") { +internal/agent/agent.go:373: log.Printf("[acpx] Parsed session ID from created line: %s", id) +internal/agent/agent.go:383: log.Printf("[acpx] Parsed session ID from created status: %s", id) +internal/agent/agent.go:389: // If no session ID found, return the session name +internal/agent/agent.go:390: log.Printf("[acpx] No sessionId in output, using session name: %s", sessionName) +internal/agent/agent.go:391: return sessionName, nil +internal/agent/agent.go:416:func (a *ACPXAgent) Prompt(ctx context.Context, sessionID, prompt string) (string, error) { +internal/agent/agent.go:417: return a.PromptWithOptions(ctx, sessionID, prompt, &PromptOptions{ +internal/agent/agent.go:423:func (a *ACPXAgent) PromptWithOptions(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (string, error) { +internal/agent/agent.go:428: return a.doPrompt(ctx, sessionID, prompt, opts) +internal/agent/agent.go:432:func (a *ACPXAgent) PromptStream(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (<-chan StreamChunk, error) { +internal/agent/agent.go:437: return a.doPromptStream(ctx, sessionID, prompt, opts) +internal/agent/agent.go:440:func (a *ACPXAgent) doPrompt(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (string, error) { +internal/agent/agent.go:446: args, timeout, format := buildPromptArgs(a.agentType, sessionID, prompt, opts, policy, false) +internal/agent/agent.go:448: log.Printf("[acpx] Sending prompt to session %s (%s, format=%s)", sessionID, policy.Summary(), format) +internal/agent/agent.go:449: log.Printf("[acpx] Prompt: %s", metrics.Truncate(prompt, 200)) +internal/agent/agent.go:453: metrics.Default().Latency(metrics.AgentExecDuration).Since(start) +internal/agent/agent.go:457: metrics.Default().Counter(metrics.PermissionDenials).Inc() +internal/agent/agent.go:458: metrics.LogEvent("permission.denied", sessionID, "", map[string]interface{}{ +internal/agent/agent.go:469:func (a *ACPXAgent) doPromptStream(ctx context.Context, sessionID, prompt string, opts *PromptOptions) (<-chan StreamChunk, error) { +internal/agent/agent.go:475: args, timeout, _ := buildPromptArgs(a.agentType, sessionID, prompt, opts, policy, true) +internal/agent/agent.go:477: log.Printf("[acpx] Streaming prompt to session %s (%s)", sessionID, policy.Summary()) +internal/agent/agent.go:478: log.Printf("[acpx] Prompt: %s", metrics.Truncate(prompt, 200)) +internal/agent/agent.go:484: metrics.Default().Counter(metrics.PermissionDenials).Inc() +internal/agent/agent.go:485: metrics.LogEvent("permission.denied", sessionID, "", map[string]interface{}{ +internal/agent/agent.go:490: metrics.Default().Counter(metrics.AgentExecFailures).Inc() +internal/agent/agent.go:501: metrics.Default().Latency(metrics.AgentExecDuration).Since(streamStart) +internal/agent/agent.go:514: AllowedTools: opts.AllowedTools, +internal/agent/agent.go:515: DeniedTools: opts.DeniedTools, +internal/agent/agent.go:521:func buildPromptArgs(agentType, sessionID, prompt string, opts *PromptOptions, policy *permission.ResolvedPolicy, streaming bool) ([]string, int, string) { +internal/agent/agent.go:556: if allowed := policy.AllowedToolsCSV(); allowed != "" { +internal/agent/agent.go:557: args = append(args, "--allowed-tools", allowed) +internal/agent/agent.go:575: args = append(args, agentType, "-s", sessionID, prompt) +internal/agent/agent.go:694: stateToolInput protocolState = "tool_input" +internal/agent/agent.go:695: stateToolOutput protocolState = "tool_output" +internal/agent/agent.go:708: toolName string +internal/agent/agent.go:709: toolInput bytes.Buffer +internal/agent/agent.go:710: toolOutput bytes.Buffer +internal/agent/agent.go:751: case stateToolInput, stateToolOutput: +internal/agent/agent.go:752: events = append(events, p.flushTool()...) +internal/agent/agent.go:785: case "tool": +internal/agent/agent.go:786: events = append(events, p.processToolMarker(content)...) +internal/agent/agent.go:806: case stateToolInput: +internal/agent/agent.go:808: p.appendLine(&p.toolInput, line) +internal/agent/agent.go:809: events = append(events, Event{Version: EventProtocolVersion, Type: TypeToolInput, Name: p.toolName, Input: line}) +internal/agent/agent.go:812: events = append(events, p.flushTool()...) +internal/agent/agent.go:816: case stateToolOutput: +internal/agent/agent.go:818: p.appendLine(&p.toolOutput, line) +internal/agent/agent.go:819: events = append(events, Event{Version: EventProtocolVersion, Type: TypeToolOutput, Name: p.toolName, Output: line}) +internal/agent/agent.go:822: events = append(events, p.flushTool()...) +internal/agent/agent.go:841:func (p *ProtocolParser) processToolMarker(content string) []Event { +internal/agent/agent.go:848: p.toolName = strings.TrimSpace(strings.TrimSuffix(content, "(pending)")) +internal/agent/agent.go:849: p.toolInput.Reset() +internal/agent/agent.go:850: p.toolOutput.Reset() +internal/agent/agent.go:851: p.state = stateToolInput +internal/agent/agent.go:852: return append(events, Event{Version: EventProtocolVersion, Type: TypeToolStart, Name: p.toolName}) +internal/agent/agent.go:857: if p.toolName == "" { +internal/agent/agent.go:858: p.toolName = strings.TrimSpace(strings.TrimSuffix(content, "(completed)")) +internal/agent/agent.go:860: p.state = stateToolOutput +internal/agent/agent.go:867: if p.toolName == "" { +internal/agent/agent.go:868: p.toolName = name +internal/agent/agent.go:873: Name: p.toolName, +internal/agent/agent.go:874: Input: strings.TrimSpace(p.toolInput.String()), +internal/agent/agent.go:875: Output: strings.TrimSpace(p.toolOutput.String()), +internal/agent/agent.go:876: Content: "tool execution failed", +internal/agent/agent.go:878: p.resetTool() +internal/agent/agent.go:883: return append(events, Event{Version: EventProtocolVersion, Type: TypeToolStart, Name: content}) +internal/agent/agent.go:891: case stateToolInput, stateToolOutput: +internal/agent/agent.go:892: return p.flushTool() +internal/agent/agent.go:912:func (p *ProtocolParser) flushTool() []Event { +internal/agent/agent.go:913: if p.toolName == "" { +internal/agent/agent.go:914: p.resetTool() +internal/agent/agent.go:921: Type: TypeToolEnd, +internal/agent/agent.go:922: Name: p.toolName, +internal/agent/agent.go:923: Input: strings.TrimSpace(p.toolInput.String()), +internal/agent/agent.go:924: Output: strings.TrimSpace(p.toolOutput.String()), +internal/agent/agent.go:926: p.resetTool() +internal/agent/agent.go:946:func (p *ProtocolParser) resetTool() { +internal/agent/agent.go:947: p.toolName = "" +internal/agent/agent.go:948: p.toolInput.Reset() +internal/agent/agent.go:949: p.toolOutput.Reset() +internal/agent/agent.go:971: case "thinking", "tool", "done", "client", "acpx": +internal/agent/agent.go:1038: strings.HasPrefix(trimmed, "[tool]") || +internal/agent/agent.go:1089: if strings.Contains(line, "[acpx]") || strings.Contains(line, "session") { +internal/event/event.go:11: // Tool events - granular tool lifecycle +internal/event/event.go:12: TypeToolStart Type = "tool_start" // Tool execution started: "ToolName (pending)" +internal/event/event.go:13: TypeToolInput Type = "tool_input" // Tool input parameters +internal/event/event.go:14: TypeToolEnd Type = "tool_end" // Tool execution completed: "ToolName (completed)" +internal/event/event.go:15: TypeToolError Type = "tool_error" // Tool execution failed +internal/event/event.go:28: Name string `json:"name,omitempty"` // Tool name for tool events +internal/event/event.go:29: Input string `json:"input,omitempty"` // Tool input for tool_input/tool_end +internal/event/event.go:30: Output string `json:"output,omitempty"` // Tool output for tool_end +internal/event/event.go:33:// IsTool returns true if the event is tool-related. +internal/event/event.go:34:func (e Event) IsTool() bool { +internal/event/event.go:35: return e.Type == TypeToolStart || e.Type == TypeToolInput || e.Type == TypeToolEnd || e.Type == TypeToolError +internal/event/event.go:40: return e.Type == TypeError || e.Type == TypeToolError +internal/event/parser.go:11:// It recognizes fine-grained tool lifecycle events like tool_start and tool_end. +internal/event/parser.go:16: // Tool state +internal/event/parser.go:17: toolName string +internal/event/parser.go:18: toolInput bytes.Buffer +internal/event/parser.go:19: toolOutput bytes.Buffer +internal/event/parser.go:20: pendingTool string // Tool name waiting for completion +internal/event/parser.go:21: collectOutput bool // Collecting tool output after (completed) +internal/event/parser.go:68: // Flush any pending tool +internal/event/parser.go:69: if p.pendingTool != "" { +internal/event/parser.go:71: Type: TypeToolEnd, +internal/event/parser.go:72: Name: p.pendingTool, +internal/event/parser.go:73: Input: strings.TrimSpace(p.toolInput.String()), +internal/event/parser.go:74: Output: strings.TrimSpace(p.toolOutput.String()), +internal/event/parser.go:76: p.pendingTool = "" +internal/event/parser.go:77: p.toolInput.Reset() +internal/event/parser.go:78: p.toolOutput.Reset() +internal/event/parser.go:103: // If collecting tool output, check if this line continues it +internal/event/parser.go:106: p.toolOutput.WriteString(line) +internal/event/parser.go:107: p.toolOutput.WriteByte('\n') +internal/event/parser.go:110: // Non-indented line ends tool output +internal/event/parser.go:112: Type: TypeToolEnd, +internal/event/parser.go:113: Name: p.pendingTool, +internal/event/parser.go:114: Input: strings.TrimSpace(p.toolInput.String()), +internal/event/parser.go:115: Output: strings.TrimSpace(p.toolOutput.String()), +internal/event/parser.go:117: p.pendingTool = "" +internal/event/parser.go:118: p.toolInput.Reset() +internal/event/parser.go:119: p.toolOutput.Reset() +internal/event/parser.go:137: case "tool": +internal/event/parser.go:138: events = append(events, p.parseToolMarker(content)...) +internal/event/parser.go:167: case TypeToolInput: +internal/event/parser.go:168: // Collect tool input lines (indented after tool_start) +internal/event/parser.go:170: p.toolInput.WriteString(line) +internal/event/parser.go:171: p.toolInput.WriteByte('\n') +internal/event/parser.go:174: // Non-indented line ends tool input, start output +internal/event/parser.go:195:// parseToolMarker parses a tool marker line and emits appropriate events. +internal/event/parser.go:196:func (p *Parser) parseToolMarker(content string) []Event { +internal/event/parser.go:200: var toolName, state string +internal/event/parser.go:202: toolName = strings.TrimSpace(strings.TrimSuffix(content, "(pending)")) +internal/event/parser.go:205: toolName = strings.TrimSpace(strings.TrimSuffix(content, "(completed)")) +internal/event/parser.go:208: toolName = strings.TrimSpace(strings.TrimSuffix(content, "(error)")) +internal/event/parser.go:212: return []Event{{Type: TypeToolStart, Content: content}} +internal/event/parser.go:217: p.toolName = toolName +internal/event/parser.go:218: p.toolInput.Reset() +internal/event/parser.go:219: p.toolOutput.Reset() +internal/event/parser.go:220: p.currentType = TypeToolInput +internal/event/parser.go:222: Type: TypeToolStart, +internal/event/parser.go:223: Name: toolName, +internal/event/parser.go:227: // Emit tool_end after collecting indented output lines +internal/event/parser.go:228: p.pendingTool = toolName +internal/event/parser.go:234: Type: TypeToolError, +internal/event/parser.go:235: Name: toolName, +internal/event/parser.go:236: Input: strings.TrimSpace(p.toolInput.String()), +internal/event/parser.go:238: p.toolName = "" +internal/event/parser.go:239: p.toolInput.Reset() +internal/event/parser.go:240: p.toolOutput.Reset() +internal/event/parser.go:289: case "thinking", "tool", "done", "client", "acpx": +docs/imclaw_design.md:12:- IM 的会话会产生一个session, 这个session也用来个acpx创建session和使用session +docs/imclaw_design.md:13:- 用户可以使用/new新建session, 也可以使用/exist使用已有的session. 这两个行为可以被同一个acpx同时使用 +internal/session/session.go:1:package session +internal/session/session.go:11:// ActivityType identifies the type of a persisted session activity. +internal/session/session.go:15: // ActivityPrompt records a user prompt submitted to a session. +internal/session/session.go:25:// Activity captures a prompt, event, result, or error in a session timeline. +internal/session/session.go:37:// Session represents a conversation session. +internal/session/session.go:38:type Session struct { +internal/session/session.go:44: AgentSession string `json:"agent_session"` // ACPX internal session ID +internal/session/session.go:45: AgentSessionHandle string `json:"agent_session_handle"` // session handle used for subsequent prompts +internal/session/session.go:46: Name string `json:"name,omitempty"` // Human-readable session name +internal/session/session.go:48: Archived bool `json:"archived"` // Archived sessions are hidden from default listings +internal/session/session.go:61:// SessionSummary is the lightweight projection used by list APIs and broadcasts. +internal/session/session.go:62:type SessionSummary struct { +internal/session/session.go:82:// Manager manages sessions. +internal/session/session.go:85: sessions map[string]*Session // session key -> session +internal/session/session.go:88:// NewManager creates a new session manager. +internal/session/session.go:91: sessions: make(map[string]*Session), +internal/session/session.go:95:// SessionKey generates a session key. +internal/session/session.go:96:func SessionKey(channel, chatID string) string { +internal/session/session.go:100:func newSession(channel, accountID, chatID, agentName string) *Session { +internal/session/session.go:102: return &Session{ +internal/session/session.go:103: ID: SessionKey(channel, chatID), +internal/session/session.go:120:func cloneSession(src *Session) *Session { +internal/session/session.go:149:func (s *Session) appendActivity(activity Activity) { +internal/session/session.go:154:func (s *Session) recordPrompt(requestID, prompt string, at time.Time) { +internal/session/session.go:172:func (s *Session) recordEvent(requestID string, evt agent.Event, at time.Time) { +internal/session/session.go:198:func (s *Session) recordResult(requestID, content string, at time.Time) { +internal/session/session.go:211:func (s *Session) recordError(requestID, message string, at time.Time) { +internal/session/session.go:224:// Summary returns a lightweight session view for list rendering. +internal/session/session.go:225:func (s *Session) Summary() SessionSummary { +internal/session/session.go:226: return SessionSummary{ +internal/session/session.go:247:// Create creates a new session. +internal/session/session.go:248:func (m *Manager) Create(channel, accountID, chatID, agentName string) *Session { +internal/session/session.go:252: key := SessionKey(channel, chatID) +internal/session/session.go:253: session := newSession(channel, accountID, chatID, agentName) +internal/session/session.go:254: m.sessions[key] = session +internal/session/session.go:255: return cloneSession(session) +internal/session/session.go:258:// Get gets a session by key. +internal/session/session.go:259:func (m *Manager) Get(channel, chatID string) (*Session, bool) { +internal/session/session.go:263: session, ok := m.sessions[SessionKey(channel, chatID)] +internal/session/session.go:264: return cloneSession(session), ok +internal/session/session.go:267:// GetOrCreate gets or creates a session. +internal/session/session.go:268:func (m *Manager) GetOrCreate(channel, accountID, chatID, defaultAgent string) *Session { +internal/session/session.go:272: key := SessionKey(channel, chatID) +internal/session/session.go:273: if sess, ok := m.sessions[key]; ok { +internal/session/session.go:275: return cloneSession(sess) +internal/session/session.go:278: session := newSession(channel, accountID, chatID, defaultAgent) +internal/session/session.go:279: m.sessions[key] = session +internal/session/session.go:280: return cloneSession(session) +internal/session/session.go:283:// Delete deletes a session. +internal/session/session.go:288: delete(m.sessions, SessionKey(channel, chatID)) +internal/session/session.go:291:// Update updates a session. +internal/session/session.go:292:func (m *Manager) Update(session *Session) { +internal/session/session.go:296: cloned := cloneSession(session) +internal/session/session.go:298: m.sessions[cloned.ID] = cloned +internal/session/session.go:301:// List lists all sessions. +internal/session/session.go:302:func (m *Manager) List() []*Session { +internal/session/session.go:306: sessions := make([]*Session, 0, len(m.sessions)) +internal/session/session.go:307: for _, sess := range m.sessions { +internal/session/session.go:308: sessions = append(sessions, cloneSession(sess)) +internal/session/session.go:310: sort.Slice(sessions, func(i, j int) bool { +internal/session/session.go:311: return sessions[i].LastActive.After(sessions[j].LastActive) +internal/session/session.go:313: return sessions +internal/session/session.go:316:// Summaries lists all sessions using a lightweight projection. +internal/session/session.go:317:func (m *Manager) Summaries() []SessionSummary { +internal/session/session.go:321: summaries := make([]SessionSummary, 0, len(m.sessions)) +internal/session/session.go:322: for _, sess := range m.sessions { +internal/session/session.go:331:// SummariesFiltered returns session summaries with optional filtering. +internal/session/session.go:332:// When tag is non-empty, only sessions with that tag are returned. +internal/session/session.go:333:// When includeArchived is false, archived sessions are excluded. +internal/session/session.go:334:func (m *Manager) SummariesFiltered(tag string, includeArchived bool) []SessionSummary { +internal/session/session.go:338: summaries := make([]SessionSummary, 0, len(m.sessions)) +internal/session/session.go:339: for _, sess := range m.sessions { +internal/session/session.go:364:// RecordPrompt appends a prompt activity to the session timeline. +internal/session/session.go:365:func (m *Manager) RecordPrompt(channel, chatID, requestID, prompt string) (*Session, bool) { +internal/session/session.go:369: sess, ok := m.sessions[SessionKey(channel, chatID)] +internal/session/session.go:374: return cloneSession(sess), true +internal/session/session.go:377:// RecordEvent appends an event activity to the session timeline. +internal/session/session.go:378:func (m *Manager) RecordEvent(channel, chatID, requestID string, evt agent.Event) (*Session, bool) { +internal/session/session.go:382: sess, ok := m.sessions[SessionKey(channel, chatID)] +internal/session/session.go:387: return cloneSession(sess), true +internal/session/session.go:390:// RecordResult appends a final result activity to the session timeline. +internal/session/session.go:391:func (m *Manager) RecordResult(channel, chatID, requestID, content string) (*Session, bool) { +internal/session/session.go:395: sess, ok := m.sessions[SessionKey(channel, chatID)] +internal/session/session.go:400: return cloneSession(sess), true +internal/session/session.go:403:// RecordError appends an error activity to the session timeline. +internal/session/session.go:404:func (m *Manager) RecordError(channel, chatID, requestID, message string) (*Session, bool) { +internal/session/session.go:408: sess, ok := m.sessions[SessionKey(channel, chatID)] +internal/session/session.go:413: return cloneSession(sess), true +internal/session/session.go:416:// Cleanup cleans up expired sessions. +internal/session/session.go:423: for key, sess := range m.sessions { +internal/session/session.go:425: delete(m.sessions, key) +internal/session/session.go:432:// Rename sets a human-readable name for the session. +internal/session/session.go:433:func (m *Manager) Rename(channel, chatID, name string) (*Session, bool) { +internal/session/session.go:437: sess, ok := m.sessions[SessionKey(channel, chatID)] +internal/session/session.go:443: return cloneSession(sess), true +internal/session/session.go:446:// AddTag adds a tag to the session. Returns the updated session or false if not found. +internal/session/session.go:447:func (m *Manager) AddTag(channel, chatID, tag string) (*Session, bool) { +internal/session/session.go:451: sess, ok := m.sessions[SessionKey(channel, chatID)] +internal/session/session.go:457: return cloneSession(sess), true +internal/session/session.go:462: return cloneSession(sess), true +internal/session/session.go:465:// RemoveTag removes a tag from the session. +internal/session/session.go:466:func (m *Manager) RemoveTag(channel, chatID, tag string) (*Session, bool) { +internal/session/session.go:470: sess, ok := m.sessions[SessionKey(channel, chatID)] +internal/session/session.go:481: return cloneSession(sess), true +internal/session/session.go:484:// SetTags replaces all tags on the session. Duplicate tags are removed. +internal/session/session.go:485:func (m *Manager) SetTags(channel, chatID string, tags []string) (*Session, bool) { +internal/session/session.go:489: sess, ok := m.sessions[SessionKey(channel, chatID)] +internal/session/session.go:495: return cloneSession(sess), true +internal/session/session.go:498:// Archive marks a session as archived. +internal/session/session.go:499:func (m *Manager) Archive(channel, chatID string) (*Session, bool) { +internal/session/session.go:503: sess, ok := m.sessions[SessionKey(channel, chatID)] +internal/session/session.go:509: return cloneSession(sess), true +internal/session/session.go:512:// Unarchive removes the archived flag from a session. +internal/session/session.go:513:func (m *Manager) Unarchive(channel, chatID string) (*Session, bool) { +internal/session/session.go:517: sess, ok := m.sessions[SessionKey(channel, chatID)] +internal/session/session.go:523: return cloneSession(sess), true +internal/session/session.go:526:// SessionUpdates contains the fields to update on a session in a single atomic operation. +internal/session/session.go:527:type SessionUpdates struct { +internal/session/session.go:535:// ApplyUpdates atomically applies multiple updates to a session within a single lock. +internal/session/session.go:537:func (m *Manager) ApplyUpdates(channel, chatID string, updates SessionUpdates) (*Session, bool) { +internal/session/session.go:541: sess, ok := m.sessions[SessionKey(channel, chatID)] +internal/session/session.go:583: return cloneSession(sess), true +internal/session/session.go:599:// ListByTag returns sessions that have the specified tag. +internal/session/session.go:600:func (m *Manager) ListByTag(tag string) []*Session { +internal/session/session.go:604: var result []*Session +internal/session/session.go:605: for _, sess := range m.sessions { +internal/session/session.go:608: result = append(result, cloneSession(sess)) +internal/session/session.go:619:// ListArchived returns all archived sessions. +internal/session/session.go:620:func (m *Manager) ListArchived() []*Session { +internal/session/session.go:624: var result []*Session +internal/session/session.go:625: for _, sess := range m.sessions { +internal/session/session.go:627: result = append(result, cloneSession(sess)) +internal/gateway/server_test.go:11: "github.com/smallnest/imclaw/internal/session" +internal/gateway/server_test.go:15: ensureSessionID string +internal/gateway/server_test.go:20:func (s stubAgent) CreateSession(ctx context.Context, sessionName string) (string, error) { +internal/gateway/server_test.go:21: return s.ensureSessionID, nil +internal/gateway/server_test.go:23:func (s stubAgent) EnsureSession(ctx context.Context, sessionName string) (string, error) { +internal/gateway/server_test.go:24: return s.ensureSessionID, nil +internal/gateway/server_test.go:26:func (s stubAgent) Prompt(ctx context.Context, sessionID, prompt string) (string, error) { +internal/gateway/server_test.go:29:func (s stubAgent) PromptWithOptions(ctx context.Context, sessionID, prompt string, opts *agent.PromptOptions) (string, error) { +internal/gateway/server_test.go:32:func (s stubAgent) PromptStream(ctx context.Context, sessionID, prompt string, opts *agent.PromptOptions) (<-chan agent.StreamChunk, error) { +internal/gateway/server_test.go:42: "allowed_tools": "Read,Grep", +internal/gateway/server_test.go:43: "denied_tools": "Grep", +internal/gateway/server_test.go:49: if opts.AllowedTools != "Read,Grep" { +internal/gateway/server_test.go:50: t.Fatalf("AllowedTools = %q", opts.AllowedTools) +internal/gateway/server_test.go:52: if opts.DeniedTools != "Grep" { +internal/gateway/server_test.go:53: t.Fatalf("DeniedTools = %q", opts.DeniedTools) +internal/gateway/server_test.go:187:func TestEnsureAgentSessionStoresInternalIDAndHandle(t *testing.T) { +internal/gateway/server_test.go:188: sessionMgr := session.NewManager() +internal/gateway/server_test.go:189: srv := NewServer(&Config{}, sessionMgr, agent.NewManager(), job.NewManager()) +internal/gateway/server_test.go:190: sess := sessionMgr.Create(defaultSessionChannel, "", "sess-ensure", "claude") +internal/gateway/server_test.go:192: handle, err := srv.ensureAgentSession(sess, stubAgent{ensureSessionID: "acpx-123"}, "req-1") +internal/gateway/server_test.go:200: updated, ok := sessionMgr.Get(defaultSessionChannel, sess.ID) +internal/gateway/server_test.go:202: t.Fatal("expected session to be updated") +internal/gateway/server_test.go:204: if updated.AgentSession != "acpx-123" { +internal/gateway/server_test.go:205: t.Fatalf("expected internal session id to be stored, got %#v", updated) +internal/gateway/server_test.go:207: if updated.AgentSessionHandle != sess.ID { +internal/gateway/server_test.go:208: t.Fatalf("expected session handle to remain stable, got %#v", updated) +internal/permission/policy_test.go:9:func TestResolvePresetAndDenyTools(t *testing.T) { +internal/permission/policy_test.go:12: DeniedTools: "Write, Bash", +internal/permission/policy_test.go:25: if contains(resolved.AllowedTools, "Write") || contains(resolved.AllowedTools, "Bash") { +internal/permission/policy_test.go:26: t.Fatalf("Denied tools still present: %#v", resolved.AllowedTools) +internal/permission/policy_test.go:28: if !contains(resolved.AllowedTools, "Read") { +internal/permission/policy_test.go:29: t.Fatalf("Expected Read to remain allowed: %#v", resolved.AllowedTools) +internal/permission/policy_test.go:36: AllowedTools: "Read,Grep", +internal/permission/policy_test.go:37: DeniedTools: "Grep", +internal/permission/policy_test.go:47: if got, want := resolved.AllowedTools, []string{"Read"}; !reflect.DeepEqual(got, want) { +internal/permission/policy_test.go:48: t.Fatalf("AllowedTools = %#v, want %#v", got, want) +internal/permission/policy_test.go:50: if got, want := resolved.DeniedTools, []string{"Grep"}; !reflect.DeepEqual(got, want) { +internal/permission/policy_test.go:51: t.Fatalf("DeniedTools = %#v, want %#v", got, want) +internal/permission/policy_test.go:61:func TestResolveRejectsUnknownTool(t *testing.T) { +internal/permission/policy_test.go:62: _, err := Resolve(Policy{AllowedTools: "Read,Nope"}) +internal/permission/policy_test.go:64: t.Fatal("expected error for unknown tool") +internal/permission/policy_test.go:66: // Verify error message includes list of valid tools +internal/permission/policy_test.go:68: if !strings.Contains(errMsg, "valid tools:") { +internal/permission/policy_test.go:69: t.Errorf("Error message should list valid tools, got: %s", errMsg) +internal/permission/policy_test.go:71: // Check that error includes some known tools +internal/permission/policy_test.go:73: t.Errorf("Error message should include known tool names, got: %s", errMsg) +internal/permission/policy_test.go:77:func TestAllowedToolsCSV(t *testing.T) { +internal/permission/policy_test.go:84: name: "empty tools", +internal/permission/policy_test.go:85: policy: &ResolvedPolicy{AllowedTools: []string{}}, +internal/permission/policy_test.go:89: name: "single tool", +internal/permission/policy_test.go:90: policy: &ResolvedPolicy{AllowedTools: []string{"Read"}}, +internal/permission/policy_test.go:94: name: "multiple tools", +internal/permission/policy_test.go:95: policy: &ResolvedPolicy{AllowedTools: []string{"Read", "Write", "Grep"}}, +internal/permission/policy_test.go:101: if got := tt.policy.AllowedToolsCSV(); got != tt.expected { +internal/permission/policy_test.go:102: t.Errorf("AllowedToolsCSV() = %q, want %q", got, tt.expected) +internal/permission/policy_test.go:130: name: "policy with allowed tools", +internal/permission/policy_test.go:133: AllowedTools: []string{"Read", "Grep"}, +internal/permission/policy_test.go:138: name: "policy with denied tools", +internal/permission/policy_test.go:141: AllowedTools: []string{"Read", "Write"}, +internal/permission/policy_test.go:142: DeniedTools: []string{"Write"}, +internal/permission/policy_test.go:151: AllowedTools: []string{"Bash", "Read", "Write"}, +internal/permission/policy_test.go:152: DeniedTools: []string{}, +internal/permission/policy_test.go:177:func TestSortedTools(t *testing.T) { +internal/permission/policy_test.go:186: expected: nil, // SortedTools returns nil for empty input +internal/permission/policy_test.go:206: if got := SortedTools(tt.input); !reflect.DeepEqual(got, tt.expected) { +internal/permission/policy_test.go:207: t.Errorf("SortedTools() = %#v, want %#v", got, tt.expected) +internal/permission/policy_test.go:221: expectedTools := []string{"Bash", "Read", "Write"} +internal/permission/policy_test.go:222: if !reflect.DeepEqual(resolved.AllowedTools, expectedTools) { +internal/permission/policy_test.go:223: t.Errorf("Default allowed tools = %#v, want %#v", resolved.AllowedTools, expectedTools) +internal/permission/policy_test.go:227:func TestResolveWithDuplicateTools(t *testing.T) { +internal/permission/policy_test.go:230: AllowedTools: "Read,Read,Write,Read,Grep,Read", +internal/permission/policy_test.go:237: for _, tool := range resolved.AllowedTools { +internal/permission/policy_test.go:238: if tool == "Read" { +internal/permission/policy_test.go:247:func TestResolveWithWhitespaceInTools(t *testing.T) { +internal/permission/policy_test.go:250: AllowedTools: " Read , Grep , Write ", +internal/permission/policy_test.go:255: expectedTools := []string{"Grep", "Read", "Write"} // Should be sorted and deduplicated +internal/permission/policy_test.go:256: if !reflect.DeepEqual(resolved.AllowedTools, expectedTools) { +internal/permission/policy_test.go:257: t.Errorf("AllowedTools with whitespace = %#v, want %#v", resolved.AllowedTools, expectedTools) +internal/permission/policy_test.go:261:func TestResolveDenyAllAllowedTools(t *testing.T) { +internal/permission/policy_test.go:264: DeniedTools: "Read,Write,Grep,Bash,Edit,Glob,LS,MultiEdit,NotebookEdit,TodoWrite,WebFetch,WebSearch", +internal/permission/policy_test.go:269: if len(resolved.AllowedTools) != 0 { +internal/permission/policy_test.go:270: t.Errorf("After denying all tools, AllowedTools = %#v, want empty slice", resolved.AllowedTools) +internal/permission/policy_test.go:274:func contains(tools []string, target string) bool { +internal/permission/policy_test.go:275: for _, tool := range tools { +internal/permission/policy_test.go:276: if tool == target { +internal/event/parser_test.go:7:func TestParserEmitsToolStartAndToolEnd(t *testing.T) { +internal/event/parser_test.go:10: raw := `[tool] Read (pending) +internal/event/parser_test.go:13:[tool] Read (completed) +internal/event/parser_test.go:21: // Should emit: tool_start, tool_end, thinking +internal/event/parser_test.go:23: var toolStart, toolEnd, thinking bool +internal/event/parser_test.go:26: case TypeToolStart: +internal/event/parser_test.go:27: toolStart = true +internal/event/parser_test.go:29: t.Errorf("expected tool name 'Read', got %q", e.Name) +internal/event/parser_test.go:31: case TypeToolEnd: +internal/event/parser_test.go:32: toolEnd = true +internal/event/parser_test.go:34: t.Errorf("expected tool name 'Read', got %q", e.Name) +internal/event/parser_test.go:41: if !toolStart { +internal/event/parser_test.go:42: t.Error("missing tool_start event") +internal/event/parser_test.go:44: if !toolEnd { +internal/event/parser_test.go:45: t.Error("missing tool_end event") +internal/event/parser_test.go:52:func TestParserHandlesToolError(t *testing.T) { +internal/event/parser_test.go:55: raw := `[tool] Write (pending) +internal/event/parser_test.go:58:[tool] Write (error) +internal/event/parser_test.go:64: // Should emit: tool_start, tool_error +internal/event/parser_test.go:65: var hasToolStart, hasToolError bool +internal/event/parser_test.go:68: case TypeToolStart: +internal/event/parser_test.go:69: hasToolStart = true +internal/event/parser_test.go:71: t.Errorf("expected tool name 'Write', got %q", e.Name) +internal/event/parser_test.go:73: case TypeToolError: +internal/event/parser_test.go:74: hasToolError = true +internal/event/parser_test.go:76: t.Errorf("expected tool name 'Write', got %q", e.Name) +internal/event/parser_test.go:81: if !hasToolStart { +internal/event/parser_test.go:82: t.Error("missing tool_start event") +internal/event/parser_test.go:84: if !hasToolError { +internal/event/parser_test.go:85: t.Error("missing tool_error event") +internal/event/parser_test.go:96:[tool] Bash (pending) +internal/event/parser_test.go:99:[tool] Bash (completed) +internal/event/parser_test.go:107: // Should have: thinking, output, tool_start, tool_end, output +internal/event/parser_test.go:108: var hasThinking, hasOutput, hasToolStart, hasToolEnd bool +internal/event/parser_test.go:115: case TypeToolStart: +internal/event/parser_test.go:116: hasToolStart = true +internal/event/parser_test.go:117: case TypeToolEnd: +internal/event/parser_test.go:118: hasToolEnd = true +internal/event/parser_test.go:128: if !hasToolStart { +internal/event/parser_test.go:129: t.Error("missing tool_start event") +internal/event/parser_test.go:131: if !hasToolEnd { +internal/event/parser_test.go:132: t.Error("missing tool_end event") +internal/event/parser_test.go:140: events := p.Feed("[tool] Read (pending)\n") +internal/event/parser_test.go:141: if len(events) != 1 || events[0].Type != TypeToolStart { +internal/event/parser_test.go:142: t.Fatalf("expected tool_start after first chunk, got %#v", events) +internal/event/parser_test.go:145: t.Fatalf("expected tool name 'Read', got %q", events[0].Name) +internal/event/parser_test.go:148: // Feed tool input - indented lines are collected, no events emitted +internal/event/parser_test.go:151: t.Fatalf("expected no events for tool input, got %#v", events) +internal/event/parser_test.go:155: events = p.Feed("[tool] Read (completed)\n output: test\n") +internal/event/parser_test.go:158: // Feed a newline to trigger tool_end +internal/event/parser_test.go:163: // Find tool_end event +internal/event/parser_test.go:164: var foundToolEnd bool +internal/event/parser_test.go:166: if e.Type == TypeToolEnd { +internal/event/parser_test.go:167: foundToolEnd = true +internal/event/parser_test.go:169: t.Errorf("expected tool name 'Read', got %q", e.Name) +internal/event/parser_test.go:173: if !foundToolEnd { +internal/event/parser_test.go:174: t.Errorf("expected tool_end event, got %#v", events) +internal/event/parser_test.go:181: raw := `[acpx] session abc123 +internal/event/parser_test.go:237:func TestEventIsTool(t *testing.T) { +internal/event/parser_test.go:242: {Event{Type: TypeToolStart}, true}, +internal/event/parser_test.go:243: {Event{Type: TypeToolEnd}, true}, +internal/event/parser_test.go:244: {Event{Type: TypeToolInput}, true}, +internal/event/parser_test.go:245: {Event{Type: TypeToolError}, true}, +internal/event/parser_test.go:252: if got := tt.event.IsTool(); got != tt.expected { +internal/event/parser_test.go:253: t.Errorf("Event{Type: %s}.IsTool() = %v, want %v", tt.event.Type, got, tt.expected) +internal/event/parser_test.go:264: {Event{Type: TypeToolError}, true}, +internal/event/parser_test.go:265: {Event{Type: TypeToolEnd}, false}, +internal/permission/policy.go:15:var knownTools = []string{ +internal/permission/policy.go:33: AllowedTools []string +internal/permission/policy.go:41: AllowedTools string +internal/permission/policy.go:42: DeniedTools string +internal/permission/policy.go:50: AllowedTools []string +internal/permission/policy.go:51: DeniedTools []string +internal/permission/policy.go:60:func KnownTools() []string { +internal/permission/policy.go:61: return append([]string(nil), knownTools...) +internal/permission/policy.go:73: AllowedTools: append([]string(nil), base.AllowedTools...), +internal/permission/policy.go:88: if policy.AllowedTools != "" { +internal/permission/policy.go:89: tools, parseErr := parseTools(policy.AllowedTools) +internal/permission/policy.go:93: resolved.AllowedTools = tools +internal/permission/policy.go:96: if policy.DeniedTools != "" { +internal/permission/policy.go:97: tools, parseErr := parseTools(policy.DeniedTools) +internal/permission/policy.go:101: resolved.DeniedTools = tools +internal/permission/policy.go:102: resolved.AllowedTools = subtractTools(resolved.AllowedTools, tools) +internal/permission/policy.go:108:func (p *ResolvedPolicy) AllowedToolsCSV() string { +internal/permission/policy.go:109: return strings.Join(p.AllowedTools, ",") +internal/permission/policy.go:117: if len(p.AllowedTools) > 0 { +internal/permission/policy.go:118: parts = append(parts, fmt.Sprintf("allowed=%s", strings.Join(p.AllowedTools, ","))) +internal/permission/policy.go:120: if len(p.DeniedTools) > 0 { +internal/permission/policy.go:121: parts = append(parts, fmt.Sprintf("denied=%s", strings.Join(p.DeniedTools, ","))) +internal/permission/policy.go:137: AllowedTools: []string{"Bash", "Read", "Write"}, +internal/permission/policy.go:146: AllowedTools: []string{"Glob", "Grep", "LS", "Read"}, +internal/permission/policy.go:152: AllowedTools: []string{"Bash", "Read", "Write"}, +internal/permission/policy.go:158: AllowedTools: KnownTools(), +internal/permission/policy.go:165:func parseTools(raw string) ([]string, error) { +internal/permission/policy.go:169: tool := strings.TrimSpace(part) +internal/permission/policy.go:170: if tool == "" { +internal/permission/policy.go:173: if !isKnownTool(tool) { +internal/permission/policy.go:174: return nil, fmt.Errorf("unknown tool %q in permission policy (valid tools: %s)", tool, strings.Join(KnownTools(), ", ")) +internal/permission/policy.go:176: set[tool] = struct{}{} +internal/permission/policy.go:179: tools := make([]string, 0, len(set)) +internal/permission/policy.go:180: for _, tool := range knownTools { +internal/permission/policy.go:181: if _, ok := set[tool]; ok { +internal/permission/policy.go:182: tools = append(tools, tool) +internal/permission/policy.go:185: return tools, nil +internal/permission/policy.go:188:func isKnownTool(tool string) bool { +internal/permission/policy.go:189: for _, known := range knownTools { +internal/permission/policy.go:190: if tool == known { +internal/permission/policy.go:197:func subtractTools(allowed, denied []string) []string { +internal/permission/policy.go:202: for _, tool := range denied { +internal/permission/policy.go:203: denySet[tool] = struct{}{} +internal/permission/policy.go:206: for _, tool := range allowed { +internal/permission/policy.go:207: if _, denied := denySet[tool]; denied { +internal/permission/policy.go:210: filtered = append(filtered, tool) +internal/permission/policy.go:215:func SortedTools(tools []string) []string { +internal/permission/policy.go:216: out := append([]string(nil), tools...) +docs/imclaw_readme.md:76:ACP 支持工具调用(Tool Use),Agent 可以使用预定义的工具集来完成任务,如读写文件、执行 Shell 命令、搜索代码等。 +docs/imclaw_readme.md:115:acpx session create --name my-session +docs/imclaw_readme.md:118:acpx session list +docs/imclaw_readme.md:121:acpx prompt --session my-session "帮我分析这段代码" +docs/imclaw_readme.md:329:Session: abc123 | Agent: claude +docs/imclaw_readme.md:361:# 第一次对话,会返回 session_id +docs/imclaw_readme.md:363:# 输出包含 session_id: xxx-xxx-xxx +docs/imclaw_readme.md:366:imclaw-cli --server ws://remote:8080/ws --session xxx-xxx-xxx -p "这个函数有什么问题?" +docs/imclaw_readme.md:427:imclaw-cli --server ws://remote:8080/ws --allowed-tools "Read,Grep,Glob" -p "分析项目结构" +docs/imclaw_readme.md:430:imclaw-cli --server ws://remote:8080/ws --allowed-tools "" -p "完全自由的 Agent" +docs/imclaw_readme.md:577:| `/session` | 显示当前会话信息 | +docs/imclaw_readme.md:587:New session created. Context cleared. +docs/imclaw_readme.md:592:> /session +docs/imclaw_readme.md:593:Current Session: +docs/imclaw_readme.md:614:| `/api/sessions` | GET | 获取所有会话 | +internal/gateway/stream_hub_test.go:17: "github.com/smallnest/imclaw/internal/session" +internal/gateway/stream_hub_test.go:49: evt := HubEvent{Event: agent.Event{Type: agent.TypeToolStart, Name: "Read"}} +internal/gateway/stream_hub_test.go:93:// TestStreamHubUnsubscribeAll verifies cleanup across sessions. +internal/gateway/stream_hub_test.go:168:// TestStreamHubNoSubscribersPublishIsNoop verifies publishing to a session with no subscribers is safe. +internal/gateway/stream_hub_test.go:181: hub.Publish("sess-1", HubEvent{Chunk: StreamChunkMsg{ID: "req-1", SessionID: "sess-1", Type: "content", Content: "hello world"}}) +internal/gateway/stream_hub_test.go:213:// TestMultipleClientsSameSession is an integration-level test using WebSocket connections. +internal/gateway/stream_hub_test.go:214:// It verifies that multiple WebSocket clients can subscribe to the same session and +internal/gateway/stream_hub_test.go:216:func TestMultipleClientsSameSession(t *testing.T) { +internal/gateway/stream_hub_test.go:217: sessionMgr := session.NewManager() +internal/gateway/stream_hub_test.go:221: srv := NewServer(&Config{Host: "127.0.0.1", Port: 0}, sessionMgr, agentMgr, jobMgr) +internal/gateway/stream_hub_test.go:223: // Create a session manually. +internal/gateway/stream_hub_test.go:224: sess := sessionMgr.Create(defaultSessionChannel, "", "multi-sess", "stub") +internal/gateway/stream_hub_test.go:247: // Read the "connected" and "session.snapshot" messages. +internal/gateway/stream_hub_test.go:255: // All clients subscribe to the same session. +internal/gateway/stream_hub_test.go:257: subReq := JSONRPCRequest{JSONRPC: "2.0", ID: fmt.Sprintf("sub-%d", i), Method: "session.subscribe", Params: map[string]interface{}{"session_id": sess.ID}} +internal/gateway/stream_hub_test.go:299:// TestSubscriberDisconnectDoesNotTerminateSession verifies that disconnecting +internal/gateway/stream_hub_test.go:301:func TestSubscriberDisconnectDoesNotTerminateSession(t *testing.T) { +internal/gateway/stream_hub_test.go:379:// TestSessionSubscribeRPC tests the JSON-RPC session.subscribe method. +internal/gateway/stream_hub_test.go:380:func TestSessionSubscribeRPC(t *testing.T) { +internal/gateway/stream_hub_test.go:381: sessionMgr := session.NewManager() +internal/gateway/stream_hub_test.go:385: srv := NewServer(&Config{}, sessionMgr, agentMgr, jobMgr) +internal/gateway/stream_hub_test.go:386: sess := sessionMgr.Create(defaultSessionChannel, "", "rpc-sess", "stub") +internal/gateway/stream_hub_test.go:388: // Test missing session_id. +internal/gateway/stream_hub_test.go:392: Method: "session.subscribe", +internal/gateway/stream_hub_test.go:395: if resp.Error == nil || resp.Error.Message != "Missing required param: session_id" { +internal/gateway/stream_hub_test.go:396: t.Fatalf("expected missing session_id error, got %v", resp) +internal/gateway/stream_hub_test.go:399: // Test non-existent session. +internal/gateway/stream_hub_test.go:403: Method: "session.subscribe", +internal/gateway/stream_hub_test.go:404: Params: map[string]interface{}{"session_id": "nonexistent"}, +internal/gateway/stream_hub_test.go:406: if resp.Error == nil || resp.Error.Message != "Session not found" { +internal/gateway/stream_hub_test.go:407: t.Fatalf("expected session not found error, got %v", resp) +internal/gateway/stream_hub_test.go:414: Method: "session.subscribe", +internal/gateway/stream_hub_test.go:415: Params: map[string]interface{}{"session_id": sess.ID}, +internal/gateway/stream_hub_test.go:433: hub.Publish("sess-1", HubEvent{Event: agent.Event{Type: agent.TypeToolStart, Name: "Bash"}}) +internal/gateway/stream_hub_test.go:447: if got.Event.Type != agent.TypeToolStart { +internal/gateway/stream_hub_test.go:507:// TestSessionSubscribeRequiresWebSocket verifies that session.subscribe fails for non-WS connections. +internal/gateway/stream_hub_test.go:508:func TestSessionSubscribeRequiresWebSocket(t *testing.T) { +internal/gateway/stream_hub_test.go:509: sessionMgr := session.NewManager() +internal/gateway/stream_hub_test.go:512: srv := NewServer(&Config{}, sessionMgr, agentMgr, jobMgr) +internal/gateway/stream_hub_test.go:513: sessionMgr.Create(defaultSessionChannel, "", "test-sess", "stub") +internal/gateway/stream_hub_test.go:519: Method: "session.subscribe", +internal/gateway/stream_hub_test.go:520: Params: map[string]interface{}{"session_id": "test-sess"}, +internal/gateway/stream_hub_test.go:530:// TestStreamHubDifferentSessionsAreIndependent verifies that subscriptions +internal/gateway/stream_hub_test.go:531:// for different sessions do not interfere with each other. +internal/gateway/stream_hub_test.go:532:func TestStreamHubDifferentSessionsAreIndependent(t *testing.T) { +cmd/imclaw-cli/main.go:28: // Session +cmd/imclaw-cli/main.go:29: sessionID = flag.StringP("session", "S", "", "Session ID to use (empty for auto-create)") +cmd/imclaw-cli/main.go:57: // Export format (for session export command) +cmd/imclaw-cli/main.go:58: exportFormat = flag.String("export-format", "json", "Export format for session export: json, markdown") +cmd/imclaw-cli/main.go:60: // Session list filters +cmd/imclaw-cli/main.go:61: listTag = flag.String("tag", "", "Filter session list by tag") +cmd/imclaw-cli/main.go:62: listArchived = flag.Bool("archived", false, "Include archived sessions in list") +cmd/imclaw-cli/main.go:70: // Tools +cmd/imclaw-cli/main.go:71: allowedTools = flag.String("allowed-tools", "", "Allowed tool names (comma-separated)") +cmd/imclaw-cli/main.go:72: deniedTools = flag.String("denied-tools", "", "Denied tool names (comma-separated)") +cmd/imclaw-cli/main.go:74: // Session control +cmd/imclaw-cli/main.go:75: maxTurns = flag.Int("max-turns", 0, "Maximum turns for the session") +cmd/imclaw-cli/main.go:103: fmt.Fprintf(os.Stderr, " session Manage sessions\n") +cmd/imclaw-cli/main.go:104: fmt.Fprintf(os.Stderr, " (session rename, session tag, session untag, session archive, session unarchive,\n") +cmd/imclaw-cli/main.go:105: fmt.Fprintf(os.Stderr, " session export, session import, session list, session get)\n") +cmd/imclaw-cli/main.go:143: // Handle session subcommands +cmd/imclaw-cli/main.go:144: if len(os.Args) > 1 && os.Args[1] == "session" { +cmd/imclaw-cli/main.go:145: handleSessionCommand() +cmd/imclaw-cli/main.go:240: AllowedTools: *allowedTools, +cmd/imclaw-cli/main.go:241: DeniedTools: *deniedTools, +cmd/imclaw-cli/main.go:263: if *sessionID != "" { +cmd/imclaw-cli/main.go:264: params["session_id"] = *sessionID +cmd/imclaw-cli/main.go:284: if allowed := policy.AllowedToolsCSV(); allowed != "" { +cmd/imclaw-cli/main.go:285: params["allowed_tools"] = allowed +cmd/imclaw-cli/main.go:287: if *deniedTools != "" { +cmd/imclaw-cli/main.go:288: params["denied_tools"] = *deniedTools +cmd/imclaw-cli/main.go:376: // Read welcome message to get session ID +cmd/imclaw-cli/main.go:383: if sid, ok := params["session_id"].(string); ok { +cmd/imclaw-cli/main.go:517:// GetSession gets the current session info +cmd/imclaw-cli/main.go:518:func (c *Client) GetSession(sessionID string) (*JSONRPCResponse, error) { +cmd/imclaw-cli/main.go:526: if sessionID != "" { +cmd/imclaw-cli/main.go:527: params["session_id"] = sessionID +cmd/imclaw-cli/main.go:533: Method: "session.get", +cmd/imclaw-cli/main.go:549:// InitSession initializes or gets the session +cmd/imclaw-cli/main.go:550:func (c *Client) InitSession(sessionID, agentType string) (*JSONRPCResponse, error) { +cmd/imclaw-cli/main.go:558: if sessionID != "" { +cmd/imclaw-cli/main.go:559: params["session_id"] = sessionID +cmd/imclaw-cli/main.go:568: Method: "session.init", +cmd/imclaw-cli/main.go:618: strings.Contains(content, "[tool]") || +cmd/imclaw-cli/main.go:719: fmt.Fprintln(stderr, "Hint: this request likely needs broader tool permission. Retry with --permission-preset full-auto or --approve-all.") +cmd/imclaw-cli/main.go:780: // Initialize session on startup +cmd/imclaw-cli/main.go:781: initResp, err := client.InitSession(*sessionID, *agentType) +cmd/imclaw-cli/main.go:783: fmt.Fprintf(os.Stderr, "Error initializing session: %v\n", err) +cmd/imclaw-cli/main.go:787: // Show session info +cmd/imclaw-cli/main.go:791: if sid, ok := result["session_id"].(string); ok { +cmd/imclaw-cli/main.go:792: fmt.Printf("Session: %s", sid) +cmd/imclaw-cli/main.go:846: case line == "/session": +cmd/imclaw-cli/main.go:847: showSession(client, *sessionID) +cmd/imclaw-cli/main.go:867: fmt.Println("New session created. Context cleared.") +cmd/imclaw-cli/main.go:903: /new Create a new session (clear context) +cmd/imclaw-cli/main.go:904: /session Show current session info +cmd/imclaw-cli/main.go:925:func showSession(client *Client, sessionID string) { +cmd/imclaw-cli/main.go:926: resp, err := client.GetSession(sessionID) +cmd/imclaw-cli/main.go:938: fmt.Println("No active session") +cmd/imclaw-cli/main.go:942: // Parse session info +cmd/imclaw-cli/main.go:943: session, ok := resp.Result.(map[string]interface{}) +cmd/imclaw-cli/main.go:949: fmt.Println("Current Session:") +cmd/imclaw-cli/main.go:950: fmt.Printf(" ID: %v\n", session["id"]) +cmd/imclaw-cli/main.go:951: fmt.Printf(" Agent: %v\n", session["agent_name"]) +cmd/imclaw-cli/main.go:952: fmt.Printf(" Agent Session: %v\n", session["agent_session"]) +cmd/imclaw-cli/main.go:953: if createdAt, ok := session["created_at"].(string); ok { +cmd/imclaw-cli/main.go:956: if lastActive, ok := session["last_active"].(string); ok { +cmd/imclaw-cli/main.go:1295:// handleSessionCommand handles session subcommands +cmd/imclaw-cli/main.go:1296:func handleSessionCommand() { +cmd/imclaw-cli/main.go:1298: fmt.Fprintf(os.Stderr, "Error: session command requires an action\n") +cmd/imclaw-cli/main.go:1299: fmt.Fprintf(os.Stderr, "Usage: %s session [options]\n", os.Args[0]) +cmd/imclaw-cli/main.go:1306: // Rebuild args without the "session" prefix for flag parsing +cmd/imclaw-cli/main.go:1307: sessionArgs := []string{os.Args[0]} +cmd/imclaw-cli/main.go:1309: sessionArgs = append(sessionArgs, os.Args[3:]...) +cmd/imclaw-cli/main.go:1312: // Parse flags for session commands +cmd/imclaw-cli/main.go:1313: flag.CommandLine.Parse(sessionArgs) +cmd/imclaw-cli/main.go:1317: handleSessionRename() +cmd/imclaw-cli/main.go:1319: handleSessionTag() +cmd/imclaw-cli/main.go:1321: handleSessionUntag() +cmd/imclaw-cli/main.go:1323: handleSessionArchive() +cmd/imclaw-cli/main.go:1325: handleSessionUnarchive() +cmd/imclaw-cli/main.go:1327: handleSessionExportCmd() +cmd/imclaw-cli/main.go:1329: handleSessionImportCmd() +cmd/imclaw-cli/main.go:1331: handleSessionList() +cmd/imclaw-cli/main.go:1333: handleSessionGetCmd() +cmd/imclaw-cli/main.go:1335: fmt.Fprintf(os.Stderr, "Error: unknown session action: %s\n", action) +cmd/imclaw-cli/main.go:1341:func handleSessionRename() { +cmd/imclaw-cli/main.go:1343: fmt.Fprintf(os.Stderr, "Error: session rename requires a session ID and name\n") +cmd/imclaw-cli/main.go:1344: fmt.Fprintf(os.Stderr, "Usage: %s session rename \n", os.Args[0]) +cmd/imclaw-cli/main.go:1347: sessionID := os.Args[3] +cmd/imclaw-cli/main.go:1353: req, err := http.NewRequest(http.MethodPatch, serverHTTP+"/api/sessions/"+sessionID, strings.NewReader(string(reqJSON))) +cmd/imclaw-cli/main.go:1361: fmt.Fprintf(os.Stderr, "Error renaming session: %v\n", err) +cmd/imclaw-cli/main.go:1368: fmt.Fprintf(os.Stderr, "Error: session not found\n") +cmd/imclaw-cli/main.go:1380: fmt.Printf("Session %s renamed to %q\n", sessionID, name) +cmd/imclaw-cli/main.go:1383:func handleSessionTag() { +cmd/imclaw-cli/main.go:1385: fmt.Fprintf(os.Stderr, "Error: session tag requires a session ID and tag\n") +cmd/imclaw-cli/main.go:1386: fmt.Fprintf(os.Stderr, "Usage: %s session tag \n", os.Args[0]) +cmd/imclaw-cli/main.go:1389: sessionID := os.Args[3] +cmd/imclaw-cli/main.go:1395: req, err := http.NewRequest(http.MethodPatch, serverHTTP+"/api/sessions/"+sessionID, strings.NewReader(string(reqJSON))) +cmd/imclaw-cli/main.go:1403: fmt.Fprintf(os.Stderr, "Error tagging session: %v\n", err) +cmd/imclaw-cli/main.go:1410: fmt.Fprintf(os.Stderr, "Error: session not found\n") +cmd/imclaw-cli/main.go:1422: fmt.Printf("Tag %q added to session %s\n", tag, sessionID) +cmd/imclaw-cli/main.go:1425:func handleSessionUntag() { +cmd/imclaw-cli/main.go:1427: fmt.Fprintf(os.Stderr, "Error: session untag requires a session ID and tag\n") +cmd/imclaw-cli/main.go:1428: fmt.Fprintf(os.Stderr, "Usage: %s session untag \n", os.Args[0]) +cmd/imclaw-cli/main.go:1431: sessionID := os.Args[3] +cmd/imclaw-cli/main.go:1437: req, err := http.NewRequest(http.MethodPatch, serverHTTP+"/api/sessions/"+sessionID, strings.NewReader(string(reqJSON))) +cmd/imclaw-cli/main.go:1445: fmt.Fprintf(os.Stderr, "Error untagging session: %v\n", err) +cmd/imclaw-cli/main.go:1460: fmt.Printf("Tag %q removed from session %s\n", tag, sessionID) +cmd/imclaw-cli/main.go:1463:func handleSessionArchive() { +cmd/imclaw-cli/main.go:1465: fmt.Fprintf(os.Stderr, "Error: session archive requires a session ID\n") +cmd/imclaw-cli/main.go:1466: fmt.Fprintf(os.Stderr, "Usage: %s session archive \n", os.Args[0]) +cmd/imclaw-cli/main.go:1469: sessionID := os.Args[3] +cmd/imclaw-cli/main.go:1474: req, err := http.NewRequest(http.MethodPatch, serverHTTP+"/api/sessions/"+sessionID, strings.NewReader(string(reqJSON))) +cmd/imclaw-cli/main.go:1482: fmt.Fprintf(os.Stderr, "Error archiving session: %v\n", err) +cmd/imclaw-cli/main.go:1489: fmt.Fprintf(os.Stderr, "Error: session not found\n") +cmd/imclaw-cli/main.go:1501: fmt.Printf("Session %s archived\n", sessionID) +cmd/imclaw-cli/main.go:1504:func handleSessionUnarchive() { +cmd/imclaw-cli/main.go:1506: fmt.Fprintf(os.Stderr, "Error: session unarchive requires a session ID\n") +cmd/imclaw-cli/main.go:1507: fmt.Fprintf(os.Stderr, "Usage: %s session unarchive \n", os.Args[0]) +cmd/imclaw-cli/main.go:1510: sessionID := os.Args[3] +cmd/imclaw-cli/main.go:1515: req, err := http.NewRequest(http.MethodPatch, serverHTTP+"/api/sessions/"+sessionID, strings.NewReader(string(reqJSON))) +cmd/imclaw-cli/main.go:1523: fmt.Fprintf(os.Stderr, "Error unarchiving session: %v\n", err) +cmd/imclaw-cli/main.go:1530: fmt.Fprintf(os.Stderr, "Error: session not found\n") +cmd/imclaw-cli/main.go:1542: fmt.Printf("Session %s unarchived\n", sessionID) +cmd/imclaw-cli/main.go:1545:func handleSessionExportCmd() { +cmd/imclaw-cli/main.go:1547: fmt.Fprintf(os.Stderr, "Error: session export requires a session ID\n") +cmd/imclaw-cli/main.go:1548: fmt.Fprintf(os.Stderr, "Usage: %s session export [--export-format json|markdown]\n", os.Args[0]) +cmd/imclaw-cli/main.go:1551: sessionID := os.Args[3] +cmd/imclaw-cli/main.go:1559: req, err := http.NewRequest(http.MethodGet, serverHTTP+"/api/sessions/export/"+sessionID+"?format="+expFormat, nil) +cmd/imclaw-cli/main.go:1566: fmt.Fprintf(os.Stderr, "Error exporting session: %v\n", err) +cmd/imclaw-cli/main.go:1573: fmt.Fprintf(os.Stderr, "Error: session not found\n") +cmd/imclaw-cli/main.go:1584:func handleSessionImportCmd() { +cmd/imclaw-cli/main.go:1586: fmt.Fprintf(os.Stderr, "Error: session import requires a file path\n") +cmd/imclaw-cli/main.go:1587: fmt.Fprintf(os.Stderr, "Usage: %s session import \n", os.Args[0]) +cmd/imclaw-cli/main.go:1602: resp, err := http.Post(serverHTTP+"/api/sessions/import", "application/json", strings.NewReader(string(reqJSON))) +cmd/imclaw-cli/main.go:1604: fmt.Fprintf(os.Stderr, "Error importing session: %v\n", err) +cmd/imclaw-cli/main.go:1630: fmt.Printf("Session imported successfully\n") +cmd/imclaw-cli/main.go:1638:func handleSessionList() { +cmd/imclaw-cli/main.go:1641: listURL := serverHTTP + "/api/sessions" +cmd/imclaw-cli/main.go:1655: fmt.Fprintf(os.Stderr, "Error fetching sessions: %v\n", err) +cmd/imclaw-cli/main.go:1666: Sessions []map[string]interface{} `json:"sessions"` +cmd/imclaw-cli/main.go:1680: fmt.Println("No sessions found") +cmd/imclaw-cli/main.go:1684: fmt.Printf("Sessions (%d total):\n\n", result.Count) +cmd/imclaw-cli/main.go:1685: for _, sess := range result.Sessions { +cmd/imclaw-cli/main.go:1708:func handleSessionGetCmd() { +cmd/imclaw-cli/main.go:1710: fmt.Fprintf(os.Stderr, "Error: session get requires a session ID\n") +cmd/imclaw-cli/main.go:1711: fmt.Fprintf(os.Stderr, "Usage: %s session get \n", os.Args[0]) +cmd/imclaw-cli/main.go:1714: sessionID := os.Args[3] +cmd/imclaw-cli/main.go:1717: resp, err := http.Get(serverHTTP + "/api/sessions/" + sessionID) +cmd/imclaw-cli/main.go:1719: fmt.Fprintf(os.Stderr, "Error fetching session: %v\n", err) +cmd/imclaw-cli/main.go:1725: fmt.Fprintf(os.Stderr, "Error: session not found\n") +cmd/imclaw-cli/main.go:1744: fmt.Printf("Session: %v\n", sess["id"]) +internal/gateway/server_ui_test.go:11: "github.com/smallnest/imclaw/internal/session" +internal/gateway/server_ui_test.go:14:func TestSessionsAPIAndDetailIncludePersistedActivity(t *testing.T) { +internal/gateway/server_ui_test.go:15: sessionMgr := session.NewManager() +internal/gateway/server_ui_test.go:17: srv := NewServer(&Config{}, sessionMgr, agentMgr, job.NewManager()) +internal/gateway/server_ui_test.go:19: sess := sessionMgr.Create("cli", "", "sess-1", "claude") +internal/gateway/server_ui_test.go:20: if _, ok := sessionMgr.RecordPrompt("cli", sess.ID, "req-1", "hello"); !ok { +internal/gateway/server_ui_test.go:23: if _, ok := sessionMgr.RecordResult("cli", sess.ID, "req-1", "world"); !ok { +internal/gateway/server_ui_test.go:27: listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) +internal/gateway/server_ui_test.go:29: srv.handleSessionsAPI(listRec, listReq) +internal/gateway/server_ui_test.go:35: Sessions []session.SessionSummary `json:"sessions"` +internal/gateway/server_ui_test.go:40: if len(listResp.Sessions) != 1 || listResp.Sessions[0].EventCount != 2 { +internal/gateway/server_ui_test.go:44: detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/sess-1", nil) +internal/gateway/server_ui_test.go:46: srv.handleSessionDetailAPI(detailRec, detailReq) +internal/gateway/server_ui_test.go:51: var detail session.Session +internal/gateway/server_ui_test.go:60:func TestHandleSessionUpdateChangesAgent(t *testing.T) { +internal/gateway/server_ui_test.go:61: sessionMgr := session.NewManager() +internal/gateway/server_ui_test.go:63: srv := NewServer(&Config{}, sessionMgr, agentMgr, job.NewManager()) +internal/gateway/server_ui_test.go:65: sess := sessionMgr.Create("cli", "", "sess-2", "claude") +internal/gateway/server_ui_test.go:66: resp := srv.handleSessionUpdate("", &JSONRPCRequest{ +internal/gateway/server_ui_test.go:69: Method: "session.update", +internal/gateway/server_ui_test.go:71: "session_id": sess.ID, +internal/gateway/server_ui_test.go:79: updated, ok := resp.Result.(*session.Session) +internal/gateway/server_ui_test.go:87: reloaded, exists := sessionMgr.Get("cli", sess.ID) +internal/gateway/server_ui_test.go:89: t.Fatal("expected session to exist after update") +internal/gateway/server_ui_test.go:96:func TestHandleSessionUpdateMissingSessionID(t *testing.T) { +internal/gateway/server_ui_test.go:97: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +internal/gateway/server_ui_test.go:98: resp := srv.handleSessionUpdate("", &JSONRPCRequest{ +internal/gateway/server_ui_test.go:101: Method: "session.update", +internal/gateway/server_ui_test.go:106: if resp.Error == nil || resp.Error.Message != "Missing required param: session_id" { +internal/gateway/server_ui_test.go:111:func TestHandleSessionUpdateMissingSession(t *testing.T) { +internal/gateway/server_ui_test.go:112: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +internal/gateway/server_ui_test.go:113: resp := srv.handleSessionUpdate("", &JSONRPCRequest{ +internal/gateway/server_ui_test.go:115: ID: "req-missing-session", +internal/gateway/server_ui_test.go:116: Method: "session.update", +internal/gateway/server_ui_test.go:118: "session_id": "does-not-exist", +internal/gateway/server_ui_test.go:122: if resp.Error == nil || resp.Error.Message != "Session not found" { +internal/gateway/server_ui_test.go:127:func TestHandleSessionDetailAPINotFound(t *testing.T) { +internal/gateway/server_ui_test.go:128: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +internal/gateway/server_ui_test.go:129: req := httptest.NewRequest(http.MethodGet, "/api/sessions/missing", nil) +internal/gateway/server_ui_test.go:132: srv.handleSessionDetailAPI(rec, req) +internal/gateway/server_ui_test.go:139: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +internal/gateway/server_ui_test.go:140: req := httptest.NewRequest(http.MethodGet, "/sessions/demo", nil) +internal/gateway/server_ui_test.go:156: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +internal/gateway/server_ui_test.go:185: srv := NewServer(&Config{DevMode: true}, session.NewManager(), agent.NewManager(), job.NewManager()) +internal/gateway/server_ui_test.go:202: srv := NewServer(&Config{DevMode: false}, session.NewManager(), agent.NewManager(), job.NewManager()) +internal/gateway/server_ui_test.go:219: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +internal/gateway/server_ui_test.go:247: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +internal/gateway/server_ui_test.go:259: srv := NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +internal/gateway/stream_hub.go:8: "github.com/smallnest/imclaw/internal/metrics" +internal/gateway/stream_hub.go:21: // Event holds a structured agent event (tool_start, output_final, etc.). +internal/gateway/stream_hub.go:34: SessionID string +internal/gateway/stream_hub.go:39:// StreamHub manages per-session fan-out of live stream events. +internal/gateway/stream_hub.go:40:// Each session can have multiple subscribers; events published to the hub +internal/gateway/stream_hub.go:44: subscribers map[string]map[string]chan HubEvent // sessionID -> subscriberID -> channel +internal/gateway/stream_hub.go:54:// Subscribe registers a subscriber for a session's live stream. +internal/gateway/stream_hub.go:55:// Returns a channel that will receive all events for that session. +internal/gateway/stream_hub.go:57:func (h *StreamHub) Subscribe(sessionID, subscriberID string) <-chan HubEvent { +internal/gateway/stream_hub.go:63: if h.subscribers[sessionID] == nil { +internal/gateway/stream_hub.go:64: h.subscribers[sessionID] = make(map[string]chan HubEvent) +internal/gateway/stream_hub.go:66: h.subscribers[sessionID][subscriberID] = ch +internal/gateway/stream_hub.go:67: metrics.Default().Gauge(metrics.WSSubscribers).Inc() +internal/gateway/stream_hub.go:72:func (h *StreamHub) Unsubscribe(sessionID, subscriberID string) { +internal/gateway/stream_hub.go:76: subs, ok := h.subscribers[sessionID] +internal/gateway/stream_hub.go:83: metrics.Default().Gauge(metrics.WSSubscribers).Dec() +internal/gateway/stream_hub.go:86: delete(h.subscribers, sessionID) +internal/gateway/stream_hub.go:90:// UnsubscribeAll removes all subscriptions for a subscriber across all sessions. +internal/gateway/stream_hub.go:95: for sessionID, subs := range h.subscribers { +internal/gateway/stream_hub.go:99: metrics.Default().Gauge(metrics.WSSubscribers).Dec() +internal/gateway/stream_hub.go:102: delete(h.subscribers, sessionID) +internal/gateway/stream_hub.go:107:// Publish sends an event to all subscribers of a session. +internal/gateway/stream_hub.go:109:func (h *StreamHub) Publish(sessionID string, evt HubEvent) { +internal/gateway/stream_hub.go:113: subs := h.subscribers[sessionID] +internal/gateway/stream_hub.go:131: log.Printf("[stream-hub] Dropping slow subscriber %s for session %s", subID, sessionID) +internal/gateway/stream_hub.go:134: metrics.Default().Counter(metrics.WSDroppedSubs).Inc() +internal/gateway/stream_hub.go:135: metrics.Default().Gauge(metrics.WSSubscribers).Dec() +internal/gateway/stream_hub.go:139: delete(h.subscribers, sessionID) +internal/gateway/stream_hub.go:143:// SubscriberCount returns the number of active subscribers for a session. +internal/gateway/stream_hub.go:144:func (h *StreamHub) SubscriberCount(sessionID string) int { +internal/gateway/stream_hub.go:147: return len(h.subscribers[sessionID]) +internal/gateway/stream_hub.go:150:// HasSubscribers returns true if the session has at least one subscriber. +internal/gateway/stream_hub.go:151:func (h *StreamHub) HasSubscribers(sessionID string) bool { +internal/gateway/stream_hub.go:154: return len(h.subscribers[sessionID]) > 0 +internal/gateway/server_lifecycle_test.go:13: "github.com/smallnest/imclaw/internal/session" +internal/gateway/server_lifecycle_test.go:17: return NewServer(&Config{}, session.NewManager(), agent.NewManager(), job.NewManager()) +internal/gateway/server_lifecycle_test.go:20:func TestHandleSessionRenameRPC(t *testing.T) { +internal/gateway/server_lifecycle_test.go:22: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-rename", "claude") +internal/gateway/server_lifecycle_test.go:27: Method: "session.rename", +internal/gateway/server_lifecycle_test.go:29: "session_id": sess.ID, +internal/gateway/server_lifecycle_test.go:37: result, ok := resp.Result.(*session.Session) +internal/gateway/server_lifecycle_test.go:39: t.Fatalf("expected *session.Session result, got %T", resp.Result) +internal/gateway/server_lifecycle_test.go:46:func TestHandleSessionRenameMissingParams(t *testing.T) { +internal/gateway/server_lifecycle_test.go:52: Method: "session.rename", +internal/gateway/server_lifecycle_test.go:53: Params: map[string]interface{}{"session_id": "nonexistent"}, +internal/gateway/server_lifecycle_test.go:60:func TestHandleSessionRenameNotFound(t *testing.T) { +internal/gateway/server_lifecycle_test.go:66: Method: "session.rename", +internal/gateway/server_lifecycle_test.go:68: "session_id": "nonexistent", +internal/gateway/server_lifecycle_test.go:73: t.Fatal("expected error for nonexistent session") +internal/gateway/server_lifecycle_test.go:77:func TestHandleSessionTagRPC(t *testing.T) { +internal/gateway/server_lifecycle_test.go:79: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-tag", "claude") +internal/gateway/server_lifecycle_test.go:84: Method: "session.tag", +internal/gateway/server_lifecycle_test.go:86: "session_id": sess.ID, +internal/gateway/server_lifecycle_test.go:94: result := resp.Result.(*session.Session) +internal/gateway/server_lifecycle_test.go:100:func TestHandleSessionUntagRPC(t *testing.T) { +internal/gateway/server_lifecycle_test.go:102: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-untag", "claude") +internal/gateway/server_lifecycle_test.go:103: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "a") +internal/gateway/server_lifecycle_test.go:104: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "b") +internal/gateway/server_lifecycle_test.go:109: Method: "session.untag", +internal/gateway/server_lifecycle_test.go:111: "session_id": sess.ID, +internal/gateway/server_lifecycle_test.go:119: result := resp.Result.(*session.Session) +internal/gateway/server_lifecycle_test.go:125:func TestHandleSessionUntagNotFound(t *testing.T) { +internal/gateway/server_lifecycle_test.go:131: Method: "session.untag", +internal/gateway/server_lifecycle_test.go:133: "session_id": "nonexistent", +internal/gateway/server_lifecycle_test.go:138: t.Fatal("expected error for nonexistent session") +internal/gateway/server_lifecycle_test.go:142:func TestHandleSessionArchiveRPC(t *testing.T) { +internal/gateway/server_lifecycle_test.go:144: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-archive", "claude") +internal/gateway/server_lifecycle_test.go:149: Method: "session.archive", +internal/gateway/server_lifecycle_test.go:151: "session_id": sess.ID, +internal/gateway/server_lifecycle_test.go:158: result := resp.Result.(*session.Session) +internal/gateway/server_lifecycle_test.go:160: t.Fatal("expected session to be archived") +internal/gateway/server_lifecycle_test.go:164:func TestHandleSessionUnarchiveRPC(t *testing.T) { +internal/gateway/server_lifecycle_test.go:166: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-unarchive", "claude") +internal/gateway/server_lifecycle_test.go:167: srv.sessionMgr.Archive(defaultSessionChannel, sess.ID) +internal/gateway/server_lifecycle_test.go:172: Method: "session.unarchive", +internal/gateway/server_lifecycle_test.go:174: "session_id": sess.ID, +internal/gateway/server_lifecycle_test.go:181: result := resp.Result.(*session.Session) +internal/gateway/server_lifecycle_test.go:183: t.Fatal("expected session to be unarchived") +internal/gateway/server_lifecycle_test.go:187:func TestHandleSessionExportRPC(t *testing.T) { +internal/gateway/server_lifecycle_test.go:189: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-export", "claude") +internal/gateway/server_lifecycle_test.go:190: srv.sessionMgr.Rename(defaultSessionChannel, sess.ID, "Exportable") +internal/gateway/server_lifecycle_test.go:191: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "test") +internal/gateway/server_lifecycle_test.go:196: Method: "session.export", +internal/gateway/server_lifecycle_test.go:198: "session_id": sess.ID, +internal/gateway/server_lifecycle_test.go:207: if result["session_id"] != sess.ID { +internal/gateway/server_lifecycle_test.go:208: t.Fatalf("expected session_id %q, got %v", sess.ID, result["session_id"]) +internal/gateway/server_lifecycle_test.go:216:func TestHandleSessionExportMarkdownRPC(t *testing.T) { +internal/gateway/server_lifecycle_test.go:218: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-export-md", "claude") +internal/gateway/server_lifecycle_test.go:219: srv.sessionMgr.Rename(defaultSessionChannel, sess.ID, "MD Export") +internal/gateway/server_lifecycle_test.go:224: Method: "session.export", +internal/gateway/server_lifecycle_test.go:226: "session_id": sess.ID, +internal/gateway/server_lifecycle_test.go:241:func TestHandleSessionExportNotFoundRPC(t *testing.T) { +internal/gateway/server_lifecycle_test.go:247: Method: "session.export", +internal/gateway/server_lifecycle_test.go:249: "session_id": "nonexistent", +internal/gateway/server_lifecycle_test.go:253: t.Fatal("expected error for nonexistent session") +internal/gateway/server_lifecycle_test.go:257:func TestHandleSessionImportRPC(t *testing.T) { +internal/gateway/server_lifecycle_test.go:260: // Create and export a session +internal/gateway/server_lifecycle_test.go:261: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-import", "claude") +internal/gateway/server_lifecycle_test.go:262: srv.sessionMgr.Rename(defaultSessionChannel, sess.ID, "Importable") +internal/gateway/server_lifecycle_test.go:263: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "import-tag") +internal/gateway/server_lifecycle_test.go:264: srv.sessionMgr.Archive(defaultSessionChannel, sess.ID) +internal/gateway/server_lifecycle_test.go:266: exported, _ := srv.sessionMgr.Get(defaultSessionChannel, sess.ID) +internal/gateway/server_lifecycle_test.go:267: data, err := session.ExportSession(exported, session.ExportJSON) +internal/gateway/server_lifecycle_test.go:272: // Delete the original session +internal/gateway/server_lifecycle_test.go:273: srv.sessionMgr.Delete(defaultSessionChannel, sess.ID) +internal/gateway/server_lifecycle_test.go:279: Method: "session.import", +internal/gateway/server_lifecycle_test.go:288: result := resp.Result.(*session.Session) +internal/gateway/server_lifecycle_test.go:293: t.Fatal("expected imported session to be archived") +internal/gateway/server_lifecycle_test.go:299: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-rmtag", "claude") +internal/gateway/server_lifecycle_test.go:300: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "keep") +internal/gateway/server_lifecycle_test.go:301: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "remove") +internal/gateway/server_lifecycle_test.go:302: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "also-remove") +internal/gateway/server_lifecycle_test.go:305: req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sess.ID, body) +internal/gateway/server_lifecycle_test.go:308: srv.handleSessionDetailAPI(rec, req) +internal/gateway/server_lifecycle_test.go:313: var result session.Session +internal/gateway/server_lifecycle_test.go:324: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-settag", "claude") +internal/gateway/server_lifecycle_test.go:325: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "old") +internal/gateway/server_lifecycle_test.go:328: req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sess.ID, body) +internal/gateway/server_lifecycle_test.go:331: srv.handleSessionDetailAPI(rec, req) +internal/gateway/server_lifecycle_test.go:336: var result session.Session +internal/gateway/server_lifecycle_test.go:347: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-clrtag", "claude") +internal/gateway/server_lifecycle_test.go:348: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "tag-a") +internal/gateway/server_lifecycle_test.go:349: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "tag-b") +internal/gateway/server_lifecycle_test.go:352: req := httptest.NewRequest(http.MethodPatch, "/api/sessions/"+sess.ID, body) +internal/gateway/server_lifecycle_test.go:355: srv.handleSessionDetailAPI(rec, req) +internal/gateway/server_lifecycle_test.go:360: var result session.Session +internal/gateway/server_lifecycle_test.go:371: srv.sessionMgr.Create(defaultSessionChannel, "", "s-arch1", "claude") +internal/gateway/server_lifecycle_test.go:372: srv.sessionMgr.Create(defaultSessionChannel, "", "s-arch2", "claude") +internal/gateway/server_lifecycle_test.go:373: srv.sessionMgr.Create(defaultSessionChannel, "", "s-arch3", "codex") +internal/gateway/server_lifecycle_test.go:374: srv.sessionMgr.Archive(defaultSessionChannel, "s-arch1") +internal/gateway/server_lifecycle_test.go:375: srv.sessionMgr.Archive(defaultSessionChannel, "s-arch3") +internal/gateway/server_lifecycle_test.go:377: req := httptest.NewRequest(http.MethodGet, "/api/sessions/archive/", nil) +internal/gateway/server_lifecycle_test.go:379: srv.handleSessionArchiveAPI(rec, req) +internal/gateway/server_lifecycle_test.go:388: sessions := result["sessions"].([]interface{}) +internal/gateway/server_lifecycle_test.go:389: if len(sessions) != 2 { +internal/gateway/server_lifecycle_test.go:390: t.Fatalf("expected 2 archived sessions, got %d", len(sessions)) +internal/gateway/server_lifecycle_test.go:396: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-exp-api", "claude") +internal/gateway/server_lifecycle_test.go:397: srv.sessionMgr.Rename(defaultSessionChannel, sess.ID, "Export API Test") +internal/gateway/server_lifecycle_test.go:399: req := httptest.NewRequest(http.MethodGet, "/api/sessions/export/"+sess.ID+"?format=json", nil) +internal/gateway/server_lifecycle_test.go:401: srv.handleSessionExportAPI(rec, req) +internal/gateway/server_lifecycle_test.go:419: sessionData := result["session"].(map[string]interface{}) +internal/gateway/server_lifecycle_test.go:420: if sessionData["id"] != sess.ID { +internal/gateway/server_lifecycle_test.go:421: t.Fatalf("expected session ID %q, got %v", sess.ID, sessionData["id"]) +internal/gateway/server_lifecycle_test.go:427: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-exp-md-api", "claude") +internal/gateway/server_lifecycle_test.go:428: srv.sessionMgr.Rename(defaultSessionChannel, sess.ID, "MD API") +internal/gateway/server_lifecycle_test.go:430: req := httptest.NewRequest(http.MethodGet, "/api/sessions/export/"+sess.ID+"?format=markdown", nil) +internal/gateway/server_lifecycle_test.go:432: srv.handleSessionExportAPI(rec, req) +internal/gateway/server_lifecycle_test.go:444: t.Fatalf("expected session name in markdown output, got:\n%s", body) +internal/gateway/server_lifecycle_test.go:450: sess := srv.sessionMgr.Create(defaultSessionChannel, "", "s-imp-api", "claude") +internal/gateway/server_lifecycle_test.go:451: srv.sessionMgr.Rename(defaultSessionChannel, sess.ID, "Import API") +internal/gateway/server_lifecycle_test.go:452: srv.sessionMgr.AddTag(defaultSessionChannel, sess.ID, "api-test") +internal/gateway/server_lifecycle_test.go:455: full, _ := srv.sessionMgr.Get(defaultSessionChannel, sess.ID) +internal/gateway/server_lifecycle_test.go:456: data, err := session.ExportSession(full, session.ExportJSON) +internal/gateway/server_lifecycle_test.go:462: srv.sessionMgr.Delete(defaultSessionChannel, sess.ID) +internal/gateway/server_lifecycle_test.go:466: req := httptest.NewRequest(http.MethodPost, "/api/sessions/import", body) +internal/gateway/server_lifecycle_test.go:469: srv.handleSessionImportAPI(rec, req) +internal/gateway/server_lifecycle_test.go:475: var result session.Session +internal/gateway/server_lifecycle_test.go:491: req := httptest.NewRequest(http.MethodPost, "/api/sessions/import", body) +internal/gateway/server_lifecycle_test.go:494: srv.handleSessionImportAPI(rec, req) +internal/gateway/server_lifecycle_test.go:501:func TestSessionsAPIExcludesArchivedByDefault(t *testing.T) { +internal/gateway/server_lifecycle_test.go:503: srv.sessionMgr.Create(defaultSessionChannel, "", "s-active", "claude") +internal/gateway/server_lifecycle_test.go:504: srv.sessionMgr.Create(defaultSessionChannel, "", "s-to-archive", "claude") +internal/gateway/server_lifecycle_test.go:505: srv.sessionMgr.Archive(defaultSessionChannel, "s-to-archive") +internal/gateway/server_lifecycle_test.go:508: req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) +internal/gateway/server_lifecycle_test.go:510: srv.handleSessionsAPI(rec, req) +internal/gateway/server_lifecycle_test.go:516: sessions := result["sessions"].([]interface{}) +internal/gateway/server_lifecycle_test.go:517: if len(sessions) != 1 { +internal/gateway/server_lifecycle_test.go:518: t.Fatalf("expected 1 active session (archived excluded), got %d", len(sessions)) +internal/gateway/server_lifecycle_test.go:522: req = httptest.NewRequest(http.MethodGet, "/api/sessions?archived=true", nil) +internal/gateway/server_lifecycle_test.go:524: srv.handleSessionsAPI(rec, req) +internal/gateway/server_lifecycle_test.go:529: sessions = result["sessions"].([]interface{}) +internal/gateway/server_lifecycle_test.go:530: if len(sessions) != 2 { +internal/gateway/server_lifecycle_test.go:531: t.Fatalf("expected 2 sessions with archived=true, got %d", len(sessions)) +internal/gateway/server_lifecycle_test.go:535:func TestSessionsAPIFilterByTag(t *testing.T) { +internal/gateway/server_lifecycle_test.go:537: srv.sessionMgr.Create(defaultSessionChannel, "", "s-t1", "claude") +internal/gateway/server_lifecycle_test.go:538: srv.sessionMgr.Create(defaultSessionChannel, "", "s-t2", "claude") +internal/gateway/server_lifecycle_test.go:539: srv.sessionMgr.AddTag(defaultSessionChannel, "s-t1", "important") +internal/gateway/server_lifecycle_test.go:540: srv.sessionMgr.AddTag(defaultSessionChannel, "s-t2", "review") +internal/gateway/server_lifecycle_test.go:542: req := httptest.NewRequest(http.MethodGet, "/api/sessions?tag=important", nil) +internal/gateway/server_lifecycle_test.go:544: srv.handleSessionsAPI(rec, req) +internal/gateway/server_lifecycle_test.go:550: sessions := result["sessions"].([]interface{}) +internal/gateway/server_lifecycle_test.go:551: if len(sessions) != 1 { +internal/gateway/server_lifecycle_test.go:552: t.Fatalf("expected 1 session with tag 'important', got %d", len(sessions)) +internal/gateway/server_lifecycle_test.go:557: mgr := session.NewManager() +internal/gateway/server_lifecycle_test.go:565: t.Fatalf("expected 1 active session, got %d", len(summaries)) +internal/gateway/server_lifecycle_test.go:571: t.Fatalf("expected 2 sessions with archived, got %d", len(summaries)) +internal/gateway/server_lifecycle_test.go:576: mgr := session.NewManager() +internal/gateway/server_lifecycle_test.go:584: t.Fatalf("expected 1 session with tag 'alpha', got %d", len(summaries)) +internal/gateway/server_lifecycle_test.go:589: t.Fatalf("expected 0 sessions, got %d", len(summaries)) +internal/gateway/ui/index.html:16: +internal/gateway/ui/index.html:29: +internal/gateway/ui/index.html:32:

IMClaw

+internal/gateway/ui/index.html:33: