feat(registry): claude_stream + mcp_server_stdio para chat con tool-use

- claude_stream_go_core: lanza claude -p --output-format stream-json
  --verbose, decodifica NDJSON y emite eventos sinteticos (text_delta,
  tool_use, tool_result, result, error) por canal Go. 10 tests con fake
  claude bash.
- mcp_server_stdio_go_infra: scaffold de MCP server JSON-RPC 2.0 sobre
  stdio (initialize, tools/list, tools/call, ping). Usuario registra
  tool defs y handler unico. 9 tests.

Usadas por apps/kanban backend para reemplazar el chat HTTP one-shot
con XML actions por WebSocket streaming + tool-use nativa.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 14:54:56 +02:00
parent 98c4982707
commit 4881eeb7de
6 changed files with 1515 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
package core
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strings"
)
// ClaudeEventType es el tipo discriminador de eventos del stream-json de claude -p.
type ClaudeEventType string
const (
ClaudeEventSystem ClaudeEventType = "system"
ClaudeEventAssistant ClaudeEventType = "assistant"
ClaudeEventUser ClaudeEventType = "user" // tool_result
ClaudeEventResult ClaudeEventType = "result" // final
ClaudeEventToolUse ClaudeEventType = "tool_use" // sintetico
ClaudeEventToolResult ClaudeEventType = "tool_result" // sintetico
ClaudeEventTextDelta ClaudeEventType = "text_delta" // sintetico (porcion legible)
ClaudeEventError ClaudeEventType = "error" // sintetico
)
// ClaudeEvent es un evento decodificado del stream. Raw siempre contiene la
// linea NDJSON original para casos no contemplados. Para los tipos comunes,
// los campos especificos vienen rellenos.
type ClaudeEvent struct {
Type ClaudeEventType `json:"type"`
Raw json.RawMessage `json:"raw,omitempty"`
// Para system/init
Subtype string `json:"subtype,omitempty"`
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
// Para text_delta (sintetico): porcion textual del mensaje del asistente
Text string `json:"text,omitempty"`
// Para tool_use (sintetico)
ToolUseID string `json:"tool_use_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
ToolInput json.RawMessage `json:"tool_input,omitempty"`
// Para tool_result (sintetico)
ToolResultID string `json:"tool_result_id,omitempty"`
ToolResultContent string `json:"tool_result_content,omitempty"`
ToolResultIsError bool `json:"tool_result_is_error,omitempty"`
// Para result (final)
StopReason string `json:"stop_reason,omitempty"`
IsError bool `json:"is_error,omitempty"`
Result string `json:"result,omitempty"`
// Para error
Error string `json:"error,omitempty"`
}
// ClaudeStreamOpts configura el lanzamiento.
type ClaudeStreamOpts struct {
Bin string // default "claude" si vacio
Args []string // args extra (NO incluyas -p ni --output-format ni --verbose; se añaden automaticamente)
Stdin io.Reader // prompt user (puede ser nil si Args lleva el prompt en posicional)
Workdir string // CWD del subprocess
Env map[string]string // env extra (se mergea con os.Environ())
Stderr io.Writer // si != nil, recibe stderr del subprocess en vivo
}
// streamRawLine es la estructura minima para detectar el tipo de una linea NDJSON.
type streamRawLine struct {
Type ClaudeEventType `json:"type"`
Subtype string `json:"subtype,omitempty"`
// system
SessionID string `json:"session_id,omitempty"`
Model string `json:"model,omitempty"`
// result
StopReason string `json:"stop_reason,omitempty"`
IsError bool `json:"is_error,omitempty"`
Result string `json:"result,omitempty"`
// assistant / user
Message *streamMessage `json:"message,omitempty"`
}
type streamMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
type contentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
ToolUseID string `json:"tool_use_id,omitempty"`
Content json.RawMessage `json:"content,omitempty"`
IsError bool `json:"is_error,omitempty"`
}
// extractToolResultContent extrae el texto de un tool_result content que puede
// ser string o array de bloques [{type:text,text:"..."}].
func extractToolResultContent(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
// Intentar como string
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
// Intentar como array de bloques
var blocks []contentBlock
if err := json.Unmarshal(raw, &blocks); err != nil {
return string(raw)
}
var sb strings.Builder
for _, b := range blocks {
if b.Type == "text" {
sb.WriteString(b.Text)
}
}
return sb.String()
}
// StreamClaude lanza `claude -p --output-format stream-json --verbose <args...>`
// y retorna un canal de eventos. El canal se cierra cuando termina el subprocess
// (EOF en stdout). El cancel del ctx mata al subprocess (SIGTERM, luego SIGKILL).
//
// La goroutine interna se encarga de:
// - Leer stdout linea a linea (NDJSON, buffer 4MB).
// - Decodificar cada linea a un evento del protocolo claude.
// - Para mensajes "assistant" expandir el array message.content emitiendo
// ClaudeEventTextDelta por cada bloque text y ClaudeEventToolUse por cada
// bloque tool_use.
// - Para mensajes "user" detectar tool_result y emitir ClaudeEventToolResult.
// - Si stdout emite linea no-JSON, emite ClaudeEventError con el contenido.
// - Capturar el exit code del subprocess; si != 0 emite ClaudeEventError final.
//
// Retorna error si el spawn falla. Si retorna chan != nil, el caller DEBE leerlo
// hasta que se cierre o cancelar el ctx.
func StreamClaude(ctx context.Context, opts ClaudeStreamOpts) (<-chan ClaudeEvent, error) {
bin := opts.Bin
if bin == "" {
var err error
bin, err = exec.LookPath("claude")
if err != nil {
return nil, fmt.Errorf("claude binary not found: %w", err)
}
}
args := append([]string{"-p", "--output-format", "stream-json", "--verbose"}, opts.Args...)
cmd := exec.CommandContext(ctx, bin, args...)
if opts.Stdin != nil {
cmd.Stdin = opts.Stdin
}
if opts.Workdir != "" {
cmd.Dir = opts.Workdir
}
// Merge env
if len(opts.Env) > 0 {
env := os.Environ()
for k, v := range opts.Env {
env = append(env, k+"="+v)
}
cmd.Env = env
}
// Stderr
stderrWriter := io.Discard
if opts.Stderr != nil {
stderrWriter = opts.Stderr
}
// Capturar stderr para reportar en error final
var stderrBuf strings.Builder
cmd.Stderr = io.MultiWriter(stderrWriter, &stderrBuf)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("stdout pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("start claude: %w", err)
}
ch := make(chan ClaudeEvent, 64)
go func() {
defer close(ch)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 4*1024*1024), 4*1024*1024)
send := func(ev ClaudeEvent) {
select {
case ch <- ev:
case <-ctx.Done():
}
}
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var raw streamRawLine
if err := json.Unmarshal(line, &raw); err != nil {
send(ClaudeEvent{
Type: ClaudeEventError,
Error: fmt.Sprintf("non-json line: %s", string(line)),
Raw: json.RawMessage(line),
})
continue
}
switch raw.Type {
case ClaudeEventSystem:
send(ClaudeEvent{
Type: ClaudeEventSystem,
Subtype: raw.Subtype,
SessionID: raw.SessionID,
Model: raw.Model,
Raw: json.RawMessage(line),
})
case ClaudeEventAssistant:
if raw.Message != nil && len(raw.Message.Content) > 0 {
var blocks []contentBlock
if err := json.Unmarshal(raw.Message.Content, &blocks); err == nil {
for _, b := range blocks {
switch b.Type {
case "text":
send(ClaudeEvent{
Type: ClaudeEventTextDelta,
Text: b.Text,
Raw: json.RawMessage(line),
})
case "tool_use":
send(ClaudeEvent{
Type: ClaudeEventToolUse,
ToolUseID: b.ID,
ToolName: b.Name,
ToolInput: b.Input,
Raw: json.RawMessage(line),
})
}
}
}
}
// Emitir tambien el evento assistant crudo
send(ClaudeEvent{
Type: ClaudeEventAssistant,
Raw: json.RawMessage(line),
})
case ClaudeEventUser:
if raw.Message != nil && len(raw.Message.Content) > 0 {
var blocks []contentBlock
if err := json.Unmarshal(raw.Message.Content, &blocks); err == nil {
for _, b := range blocks {
if b.Type == "tool_result" {
content := extractToolResultContent(b.Content)
send(ClaudeEvent{
Type: ClaudeEventToolResult,
ToolResultID: b.ToolUseID,
ToolResultContent: content,
ToolResultIsError: b.IsError,
Raw: json.RawMessage(line),
})
}
}
}
}
send(ClaudeEvent{
Type: ClaudeEventUser,
Raw: json.RawMessage(line),
})
case ClaudeEventResult:
send(ClaudeEvent{
Type: ClaudeEventResult,
Subtype: raw.Subtype,
SessionID: raw.SessionID,
StopReason: raw.StopReason,
IsError: raw.IsError,
Result: raw.Result,
Raw: json.RawMessage(line),
})
default:
send(ClaudeEvent{
Type: raw.Type,
Raw: json.RawMessage(line),
})
}
}
// Esperar a que termine el subprocess
if err := cmd.Wait(); err != nil {
if ctx.Err() != nil {
// Cancelado por contexto — salida limpia
return
}
stderr := strings.TrimSpace(stderrBuf.String())
errMsg := fmt.Sprintf("claude exit error: %v", err)
if stderr != "" {
// Solo las ultimas lineas para no saturar
lines := strings.Split(stderr, "\n")
tail := lines
if len(lines) > 5 {
tail = lines[len(lines)-5:]
}
errMsg = fmt.Sprintf("claude exit error: %v: %s", err, strings.Join(tail, "; "))
}
send(ClaudeEvent{
Type: ClaudeEventError,
Error: errMsg,
})
}
}()
return ch, nil
}
+103
View File
@@ -0,0 +1,103 @@
---
name: claude_stream
kind: function
lang: go
domain: core
version: "1.0.0"
purity: impure
signature: "func StreamClaude(ctx context.Context, opts ClaudeStreamOpts) (<-chan ClaudeEvent, error)"
description: "Lanza `claude -p --output-format stream-json --verbose` como subprocess y retorna un canal de eventos decodificados (text_delta, tool_use, tool_result, result, error). Expande automaticamente los bloques de contenido de los mensajes assistant/user en eventos sinteticos de grano fino."
tags: [claude, streaming, subprocess, agent, ndjson, tool-use]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports:
- bufio
- context
- encoding/json
- fmt
- io
- os
- os/exec
- strings
tested: true
tests:
- "system event"
- "text delta"
- "multiple text blocks"
- "tool use"
- "tool result string content"
- "tool result array content"
- "result event"
- "non-zero exit"
- "non-json line"
- "ctx cancel"
test_file_path: "functions/core/claude_stream_test.go"
file_path: "functions/core/claude_stream.go"
params:
- name: ctx
desc: "Contexto de cancelacion. Al cancelar, el subprocess recibe SIGTERM/SIGKILL y el canal se cierra."
- name: opts
desc: "Opciones de lanzamiento: Bin (path al binario claude, default 'claude'), Args (args extra sin -p ni --output-format ni --verbose), Stdin (prompt como io.Reader), Workdir (CWD del subprocess), Env (env extra mergeado con os.Environ()), Stderr (destino del stderr del subprocess)."
output: "Canal de ClaudeEvent cerrado cuando el subprocess termina. Cada evento tiene Type discriminador y campos especificos segun el tipo. Raw contiene siempre la linea NDJSON original. Retorna error solo si el spawn falla."
---
## Tipos exportados
**ClaudeEventType** — constantes de tipo de evento:
- `system` — evento de inicializacion con session_id y model
- `assistant` — mensaje raw del asistente (tambien genera text_delta y/o tool_use sinteticos)
- `user` — mensaje raw de usuario/tool_result (tambien genera tool_result sintetico)
- `result` — evento final con stop_reason, is_error, result
- `text_delta` — (sintetico) porcion de texto del asistente
- `tool_use` — (sintetico) llamada a herramienta con tool_use_id, tool_name, tool_input
- `tool_result` — (sintetico) resultado de herramienta con tool_result_id, content, is_error
- `error` — (sintetico) linea no-JSON o exit code != 0
**ClaudeStreamOpts** — configura el subprocess:
- `Bin string` — path al binario. Si vacio, usa `exec.LookPath("claude")`.
- `Args []string` — args extra. Se anteponen automaticamente `-p --output-format stream-json --verbose`.
- `Stdin io.Reader` — prompt user. Puede ser `strings.NewReader("prompt")` o nil.
- `Workdir string` — CWD del subprocess.
- `Env map[string]string` — variables extra mergeadas con `os.Environ()`.
- `Stderr io.Writer` — destino del stderr en vivo (ej. `os.Stderr` para debug). Si nil, se descarta.
## Ejemplo
```go
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
ch, err := core.StreamClaude(ctx, core.ClaudeStreamOpts{
Args: []string{"responde en una frase: que es Go"},
Stderr: os.Stderr,
})
if err != nil {
log.Fatal(err)
}
for ev := range ch {
switch ev.Type {
case core.ClaudeEventTextDelta:
fmt.Print(ev.Text)
case core.ClaudeEventToolUse:
fmt.Printf("\n[tool] %s(%s)\n", ev.ToolName, ev.ToolInput)
case core.ClaudeEventToolResult:
fmt.Printf("[result] %s\n", ev.ToolResultContent)
case core.ClaudeEventResult:
fmt.Printf("\n[done] stop_reason=%s\n", ev.StopReason)
case core.ClaudeEventError:
fmt.Fprintf(os.Stderr, "[error] %s\n", ev.Error)
}
}
```
## Notas
- El caller DEBE consumir el canal hasta que se cierre, o cancelar el ctx. No consumir bloquea la goroutine interna.
- El canal tiene buffer de 64 para absorber ráfagas sin bloquear la lectura de stdout.
- Los eventos `assistant` y `user` raw se emiten ademas de los sinteticos, para casos no contemplados.
- `tool_result.content` puede ser string o array `[{type:text,text:"..."}]` — la funcion concatena los bloques text en ambos casos.
- Los tests usan un fake claude bash; se skipean si bash no esta disponible en el PATH.
- Equivalente Go de `projects/osint_graph/apps/graph_explorer/chat.cpp` (C++).
+354
View File
@@ -0,0 +1,354 @@
package core
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
// makeFakeClaude crea un script bash temporal que escribe las lineas dadas a
// stdout y termina con el exit code indicado.
func makeFakeClaude(t *testing.T, lines []string, exitCode int) string {
t.Helper()
// Skip si bash no esta disponible
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available, skipping claude_stream tests")
}
dir := t.TempDir()
script := filepath.Join(dir, "claude")
var sb string
sb = "#!/usr/bin/env bash\n"
for _, l := range lines {
// Escapar comillas simples para echo
escaped := ""
for _, ch := range l {
if ch == '\'' {
escaped += "'\\''"
} else {
escaped += string(ch)
}
}
sb += fmt.Sprintf("printf '%%s\\n' '%s'\n", escaped)
}
if exitCode != 0 {
sb += fmt.Sprintf("exit %d\n", exitCode)
}
if err := os.WriteFile(script, []byte(sb), 0o755); err != nil {
t.Fatalf("write fake claude: %v", err)
}
return script
}
// collectEvents drena el canal con timeout.
func collectEvents(t *testing.T, ch <-chan ClaudeEvent, timeout time.Duration) []ClaudeEvent {
t.Helper()
var events []ClaudeEvent
deadline := time.After(timeout)
for {
select {
case ev, ok := <-ch:
if !ok {
return events
}
events = append(events, ev)
case <-deadline:
t.Fatal("timeout waiting for events channel to close")
}
}
}
func TestStreamClaude_SystemEvent(t *testing.T) {
line := `{"type":"system","subtype":"init","session_id":"abc123","model":"claude-sonnet-4-5"}`
bin := makeFakeClaude(t, []string{line}, 0)
ch, err := StreamClaude(context.Background(), ClaudeStreamOpts{Bin: bin})
if err != nil {
t.Fatalf("StreamClaude error: %v", err)
}
events := collectEvents(t, ch, 5*time.Second)
var found *ClaudeEvent
for i := range events {
if events[i].Type == ClaudeEventSystem {
found = &events[i]
break
}
}
if found == nil {
t.Fatalf("no system event found; got %v", events)
}
if found.SessionID != "abc123" {
t.Errorf("session_id: got %q, want %q", found.SessionID, "abc123")
}
if found.Model != "claude-sonnet-4-5" {
t.Errorf("model: got %q, want %q", found.Model, "claude-sonnet-4-5")
}
if found.Subtype != "init" {
t.Errorf("subtype: got %q, want %q", found.Subtype, "init")
}
}
func TestStreamClaude_TextDelta(t *testing.T) {
line := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hola mundo"}]}}`
bin := makeFakeClaude(t, []string{line}, 0)
ch, err := StreamClaude(context.Background(), ClaudeStreamOpts{Bin: bin})
if err != nil {
t.Fatalf("StreamClaude error: %v", err)
}
events := collectEvents(t, ch, 5*time.Second)
var found *ClaudeEvent
for i := range events {
if events[i].Type == ClaudeEventTextDelta {
found = &events[i]
break
}
}
if found == nil {
t.Fatalf("no text_delta event found; got %v", events)
}
if found.Text != "hola mundo" {
t.Errorf("text: got %q, want %q", found.Text, "hola mundo")
}
}
func TestStreamClaude_MultipleTextBlocks(t *testing.T) {
// Un solo mensaje assistant con dos bloques text
line := `{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"primero"},{"type":"text","text":"segundo"}]}}`
bin := makeFakeClaude(t, []string{line}, 0)
ch, err := StreamClaude(context.Background(), ClaudeStreamOpts{Bin: bin})
if err != nil {
t.Fatalf("StreamClaude error: %v", err)
}
events := collectEvents(t, ch, 5*time.Second)
var deltas []ClaudeEvent
for _, ev := range events {
if ev.Type == ClaudeEventTextDelta {
deltas = append(deltas, ev)
}
}
if len(deltas) != 2 {
t.Fatalf("expected 2 text_delta events, got %d: %v", len(deltas), deltas)
}
if deltas[0].Text != "primero" {
t.Errorf("delta[0].text: got %q, want %q", deltas[0].Text, "primero")
}
if deltas[1].Text != "segundo" {
t.Errorf("delta[1].text: got %q, want %q", deltas[1].Text, "segundo")
}
}
func TestStreamClaude_ToolUse(t *testing.T) {
inputJSON := `{"column_id":"col1","title":"nueva card"}`
line := fmt.Sprintf(`{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_abc","name":"create_card","input":%s}]}}`, inputJSON)
bin := makeFakeClaude(t, []string{line}, 0)
ch, err := StreamClaude(context.Background(), ClaudeStreamOpts{Bin: bin})
if err != nil {
t.Fatalf("StreamClaude error: %v", err)
}
events := collectEvents(t, ch, 5*time.Second)
var found *ClaudeEvent
for i := range events {
if events[i].Type == ClaudeEventToolUse {
found = &events[i]
break
}
}
if found == nil {
t.Fatalf("no tool_use event found; got %v", events)
}
if found.ToolUseID != "toolu_abc" {
t.Errorf("tool_use_id: got %q, want %q", found.ToolUseID, "toolu_abc")
}
if found.ToolName != "create_card" {
t.Errorf("tool_name: got %q, want %q", found.ToolName, "create_card")
}
var input map[string]string
if err := json.Unmarshal(found.ToolInput, &input); err != nil {
t.Fatalf("unmarshal tool_input: %v", err)
}
if input["title"] != "nueva card" {
t.Errorf("tool_input.title: got %q, want %q", input["title"], "nueva card")
}
}
func TestStreamClaude_ToolResultStringContent(t *testing.T) {
line := `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_abc","content":"card creada exitosamente","is_error":false}]}}`
bin := makeFakeClaude(t, []string{line}, 0)
ch, err := StreamClaude(context.Background(), ClaudeStreamOpts{Bin: bin})
if err != nil {
t.Fatalf("StreamClaude error: %v", err)
}
events := collectEvents(t, ch, 5*time.Second)
var found *ClaudeEvent
for i := range events {
if events[i].Type == ClaudeEventToolResult {
found = &events[i]
break
}
}
if found == nil {
t.Fatalf("no tool_result event found; got %v", events)
}
if found.ToolResultID != "toolu_abc" {
t.Errorf("tool_result_id: got %q, want %q", found.ToolResultID, "toolu_abc")
}
if found.ToolResultContent != "card creada exitosamente" {
t.Errorf("content: got %q, want %q", found.ToolResultContent, "card creada exitosamente")
}
if found.ToolResultIsError {
t.Error("is_error should be false")
}
}
func TestStreamClaude_ToolResultArrayContent(t *testing.T) {
line := `{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_xyz","content":[{"type":"text","text":"parte a"},{"type":"text","text":"parte b"}],"is_error":false}]}}`
bin := makeFakeClaude(t, []string{line}, 0)
ch, err := StreamClaude(context.Background(), ClaudeStreamOpts{Bin: bin})
if err != nil {
t.Fatalf("StreamClaude error: %v", err)
}
events := collectEvents(t, ch, 5*time.Second)
var found *ClaudeEvent
for i := range events {
if events[i].Type == ClaudeEventToolResult {
found = &events[i]
break
}
}
if found == nil {
t.Fatalf("no tool_result event found; got %v", events)
}
if found.ToolResultContent != "parte aparte b" {
t.Errorf("content: got %q, want %q", found.ToolResultContent, "parte aparte b")
}
}
func TestStreamClaude_ResultEvent(t *testing.T) {
line := `{"type":"result","subtype":"success","is_error":false,"result":"respuesta final","stop_reason":"end_turn","session_id":"sess1"}`
bin := makeFakeClaude(t, []string{line}, 0)
ch, err := StreamClaude(context.Background(), ClaudeStreamOpts{Bin: bin})
if err != nil {
t.Fatalf("StreamClaude error: %v", err)
}
events := collectEvents(t, ch, 5*time.Second)
var found *ClaudeEvent
for i := range events {
if events[i].Type == ClaudeEventResult {
found = &events[i]
break
}
}
if found == nil {
t.Fatalf("no result event found; got %v", events)
}
if found.StopReason != "end_turn" {
t.Errorf("stop_reason: got %q, want %q", found.StopReason, "end_turn")
}
if found.IsError {
t.Error("is_error should be false")
}
if found.Result != "respuesta final" {
t.Errorf("result: got %q, want %q", found.Result, "respuesta final")
}
}
func TestStreamClaude_NonZeroExit(t *testing.T) {
bin := makeFakeClaude(t, nil, 7)
ch, err := StreamClaude(context.Background(), ClaudeStreamOpts{Bin: bin})
if err != nil {
t.Fatalf("StreamClaude error: %v", err)
}
events := collectEvents(t, ch, 5*time.Second)
var found *ClaudeEvent
for i := range events {
if events[i].Type == ClaudeEventError {
found = &events[i]
break
}
}
if found == nil {
t.Fatalf("no error event for non-zero exit; got %v", events)
}
if found.Error == "" {
t.Error("error message should not be empty")
}
}
func TestStreamClaude_NonJsonLine(t *testing.T) {
bin := makeFakeClaude(t, []string{"esto no es json"}, 0)
ch, err := StreamClaude(context.Background(), ClaudeStreamOpts{Bin: bin})
if err != nil {
t.Fatalf("StreamClaude error: %v", err)
}
events := collectEvents(t, ch, 5*time.Second)
var found *ClaudeEvent
for i := range events {
if events[i].Type == ClaudeEventError {
found = &events[i]
break
}
}
if found == nil {
t.Fatalf("no error event for non-json line; got %v", events)
}
}
func TestStreamClaude_CtxCancel(t *testing.T) {
// Fake claude que duerme indefinidamente
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
dir := t.TempDir()
script := filepath.Join(dir, "claude")
if err := os.WriteFile(script, []byte("#!/usr/bin/env bash\nsleep 60\n"), 0o755); err != nil {
t.Fatalf("write sleep script: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
ch, err := StreamClaude(ctx, ClaudeStreamOpts{Bin: script})
if err != nil {
t.Fatalf("StreamClaude error: %v", err)
}
// Cancelar enseguida
cancel()
// El canal debe cerrarse en menos de 1 segundo
deadline := time.After(1 * time.Second)
for {
select {
case _, ok := <-ch:
if !ok {
return // canal cerrado: test OK
}
case <-deadline:
t.Fatal("channel did not close within 1s after ctx cancel")
}
}
}