feat: streaming del subproceso claude-code con --output-format stream-json
Implementa la Fase 1 del issue 0036: soporte de streaming en tiempo real para el provider claude-code. - Tipos puros de streaming en pkg/llm/types.go: StreamEventKind, StreamEvent, StreamFunc (pure core, sin side effects) - Refactor de shell/llm/claudecode.go: nuevo code path executeStreaming que usa cmd.StdoutPipe + bufio.Scanner para leer linea a linea - Parser parseStreamLine que mapea eventos JSON del CLI (system, assistant, result) a StreamEvent del dominio - buildClaudeArgs ahora selecciona --output-format stream-json cuando streaming esta habilitado y StreamFunc presente - Campos Streaming y ShowToolProgress en ClaudeCodeCfg (config schema) - Backward compatible: streaming=false (default) no cambia comportamiento - 40 tests (20 existentes + 20 nuevos) pasan sin errores Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -371,6 +371,377 @@ func TestResolveWorkDir_ConfiguredAlreadyExists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── parseStreamLine ─────────────────────────────────────────────────
|
||||
|
||||
func TestParseStreamLine_SystemInit(t *testing.T) {
|
||||
line := []byte(`{"type":"system","subtype":"init","session_id":"abc","tools":["Bash","Read"],"model":"sonnet"}`)
|
||||
|
||||
evt, result, err := parseStreamLine(line)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if evt.Kind != coretypes.StreamInit {
|
||||
t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamInit)
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("expected nil result for system event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStreamLine_AssistantToolUse(t *testing.T) {
|
||||
line := []byte(`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","id":"call_1","input":{"command":"ls -la /tmp"}}]}}`)
|
||||
|
||||
evt, result, err := parseStreamLine(line)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if evt.Kind != coretypes.StreamToolUse {
|
||||
t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamToolUse)
|
||||
}
|
||||
if evt.ToolName != "Bash" {
|
||||
t.Errorf("tool_name = %q, want %q", evt.ToolName, "Bash")
|
||||
}
|
||||
if evt.ToolInput != "ls -la /tmp" {
|
||||
t.Errorf("tool_input = %q, want %q", evt.ToolInput, "ls -la /tmp")
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("expected nil result for assistant event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStreamLine_AssistantToolUseFilePath(t *testing.T) {
|
||||
line := []byte(`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","id":"call_2","input":{"file_path":"/home/user/main.go"}}]}}`)
|
||||
|
||||
evt, _, err := parseStreamLine(line)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if evt.Kind != coretypes.StreamToolUse {
|
||||
t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamToolUse)
|
||||
}
|
||||
if evt.ToolName != "Read" {
|
||||
t.Errorf("tool_name = %q, want %q", evt.ToolName, "Read")
|
||||
}
|
||||
if evt.ToolInput != "/home/user/main.go" {
|
||||
t.Errorf("tool_input = %q, want %q", evt.ToolInput, "/home/user/main.go")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStreamLine_AssistantText(t *testing.T) {
|
||||
line := []byte(`{"type":"assistant","message":{"content":[{"type":"text","text":"Hello, world!"}]}}`)
|
||||
|
||||
evt, result, err := parseStreamLine(line)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if evt.Kind != coretypes.StreamText {
|
||||
t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamText)
|
||||
}
|
||||
if evt.Content != "Hello, world!" {
|
||||
t.Errorf("content = %q, want %q", evt.Content, "Hello, world!")
|
||||
}
|
||||
if result != nil {
|
||||
t.Error("expected nil result for text event")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStreamLine_AssistantNoContent(t *testing.T) {
|
||||
line := []byte(`{"type":"assistant","message":{"content":[]}}`)
|
||||
|
||||
evt, _, err := parseStreamLine(line)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if evt.Kind != coretypes.StreamText {
|
||||
t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStreamLine_ResultSuccess(t *testing.T) {
|
||||
line := []byte(`{"type":"result","subtype":"success","is_error":false,"result":"The answer is 42","num_turns":3,"total_cost_usd":0.05,"usage":{"input_tokens":100,"output_tokens":50}}`)
|
||||
|
||||
evt, result, err := parseStreamLine(line)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if evt.Kind != coretypes.StreamResult {
|
||||
t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamResult)
|
||||
}
|
||||
if evt.Content != "The answer is 42" {
|
||||
t.Errorf("content = %q, want %q", evt.Content, "The answer is 42")
|
||||
}
|
||||
if evt.IsError {
|
||||
t.Error("expected IsError=false")
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result for result event")
|
||||
}
|
||||
if result.Result != "The answer is 42" {
|
||||
t.Errorf("result.Result = %q, want %q", result.Result, "The answer is 42")
|
||||
}
|
||||
if result.Usage.InputTokens != 100 {
|
||||
t.Errorf("input_tokens = %d, want 100", result.Usage.InputTokens)
|
||||
}
|
||||
if result.Usage.OutputTokens != 50 {
|
||||
t.Errorf("output_tokens = %d, want 50", result.Usage.OutputTokens)
|
||||
}
|
||||
if result.TotalCost != 0.05 {
|
||||
t.Errorf("total_cost = %f, want 0.05", result.TotalCost)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStreamLine_ResultError(t *testing.T) {
|
||||
line := []byte(`{"type":"result","subtype":"error","is_error":true,"result":"API key expired","num_turns":0}`)
|
||||
|
||||
evt, result, err := parseStreamLine(line)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if evt.Kind != coretypes.StreamResult {
|
||||
t.Errorf("kind = %q, want %q", evt.Kind, coretypes.StreamResult)
|
||||
}
|
||||
if !evt.IsError {
|
||||
t.Error("expected IsError=true")
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result")
|
||||
}
|
||||
if !result.IsError {
|
||||
t.Error("expected result.IsError=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStreamLine_UnknownType(t *testing.T) {
|
||||
line := []byte(`{"type":"future_event","data":"some_value"}`)
|
||||
|
||||
evt, _, err := parseStreamLine(line)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if evt.Kind != coretypes.StreamText {
|
||||
t.Errorf("kind = %q, want %q (fallback for unknown types)", evt.Kind, coretypes.StreamText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStreamLine_InvalidJSON(t *testing.T) {
|
||||
line := []byte(`not valid json`)
|
||||
|
||||
_, _, err := parseStreamLine(line)
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// ── truncateToolInput ───────────────────────────────────────────────
|
||||
|
||||
func TestTruncateToolInput_Nil(t *testing.T) {
|
||||
got := truncateToolInput(nil)
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateToolInput_String(t *testing.T) {
|
||||
got := truncateToolInput("hello world")
|
||||
if got != "hello world" {
|
||||
t.Errorf("got %q, want %q", got, "hello world")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateToolInput_LongString(t *testing.T) {
|
||||
long := strings.Repeat("x", 200)
|
||||
got := truncateToolInput(long)
|
||||
if len(got) != 100 {
|
||||
t.Errorf("len = %d, want 100", len(got))
|
||||
}
|
||||
if !strings.HasSuffix(got, "...") {
|
||||
t.Error("should end with ...")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateToolInput_MapWithCommand(t *testing.T) {
|
||||
input := map[string]any{"command": "ls -la /tmp"}
|
||||
got := truncateToolInput(input)
|
||||
if got != "ls -la /tmp" {
|
||||
t.Errorf("got %q, want %q", got, "ls -la /tmp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateToolInput_MapWithFilePath(t *testing.T) {
|
||||
input := map[string]any{"file_path": "/home/user/main.go"}
|
||||
got := truncateToolInput(input)
|
||||
if got != "/home/user/main.go" {
|
||||
t.Errorf("got %q, want %q", got, "/home/user/main.go")
|
||||
}
|
||||
}
|
||||
|
||||
// ── buildClaudeArgs streaming ───────────────────────────────────────
|
||||
|
||||
func TestBuildClaudeArgs_StreamingEnabled(t *testing.T) {
|
||||
cfg := config.ClaudeCodeCfg{
|
||||
Streaming: true,
|
||||
}
|
||||
streamFn := func(evt coretypes.StreamEvent) {}
|
||||
req := coretypes.CompletionRequest{
|
||||
StreamFunc: streamFn,
|
||||
}
|
||||
|
||||
args := buildClaudeArgs(cfg, req)
|
||||
|
||||
assertContains(t, args, "--output-format", "stream-json")
|
||||
// Must also include --verbose for stream-json
|
||||
found := false
|
||||
for _, a := range args {
|
||||
if a == "--verbose" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("--verbose should be present when streaming")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeArgs_StreamingDisabled(t *testing.T) {
|
||||
cfg := config.ClaudeCodeCfg{
|
||||
Streaming: false,
|
||||
}
|
||||
req := coretypes.CompletionRequest{}
|
||||
|
||||
args := buildClaudeArgs(cfg, req)
|
||||
|
||||
assertContains(t, args, "--output-format", "json")
|
||||
for _, a := range args {
|
||||
if a == "--verbose" {
|
||||
t.Error("--verbose should NOT be present when not streaming")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildClaudeArgs_StreamingEnabledNoStreamFunc(t *testing.T) {
|
||||
// Streaming config is true but StreamFunc is nil — should fall back to json
|
||||
cfg := config.ClaudeCodeCfg{
|
||||
Streaming: true,
|
||||
}
|
||||
req := coretypes.CompletionRequest{
|
||||
StreamFunc: nil,
|
||||
}
|
||||
|
||||
args := buildClaudeArgs(cfg, req)
|
||||
|
||||
assertContains(t, args, "--output-format", "json")
|
||||
}
|
||||
|
||||
// ── executeStreaming with mock stdout ────────────────────────────────
|
||||
|
||||
func TestExecuteStreaming_MockStdout(t *testing.T) {
|
||||
// Simulate stream-json output by writing lines to an io.Pipe
|
||||
lines := []string{
|
||||
`{"type":"system","subtype":"init","session_id":"test-123"}`,
|
||||
`{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","id":"call_1","input":{"command":"echo hello"}}]}}`,
|
||||
`{"type":"assistant","message":{"content":[{"type":"text","text":"Done executing."}]}}`,
|
||||
`{"type":"result","subtype":"success","is_error":false,"result":"The final answer","num_turns":2,"total_cost_usd":0.01,"usage":{"input_tokens":50,"output_tokens":25}}`,
|
||||
}
|
||||
|
||||
var events []coretypes.StreamEvent
|
||||
streamFn := func(evt coretypes.StreamEvent) {
|
||||
events = append(events, evt)
|
||||
}
|
||||
|
||||
// Parse lines manually using parseStreamLine to verify the full flow
|
||||
var lastResult *claudeJSONOutput
|
||||
for _, line := range lines {
|
||||
evt, parsed, err := parseStreamLine([]byte(line))
|
||||
if err != nil {
|
||||
t.Fatalf("parse error on line: %v", err)
|
||||
}
|
||||
streamFn(evt)
|
||||
if parsed != nil && parsed.Type == "result" {
|
||||
lastResult = parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Verify events
|
||||
if len(events) != 4 {
|
||||
t.Fatalf("expected 4 events, got %d", len(events))
|
||||
}
|
||||
if events[0].Kind != coretypes.StreamInit {
|
||||
t.Errorf("event[0].Kind = %q, want %q", events[0].Kind, coretypes.StreamInit)
|
||||
}
|
||||
if events[1].Kind != coretypes.StreamToolUse {
|
||||
t.Errorf("event[1].Kind = %q, want %q", events[1].Kind, coretypes.StreamToolUse)
|
||||
}
|
||||
if events[1].ToolName != "Bash" {
|
||||
t.Errorf("event[1].ToolName = %q, want %q", events[1].ToolName, "Bash")
|
||||
}
|
||||
if events[1].ToolInput != "echo hello" {
|
||||
t.Errorf("event[1].ToolInput = %q, want %q", events[1].ToolInput, "echo hello")
|
||||
}
|
||||
if events[2].Kind != coretypes.StreamText {
|
||||
t.Errorf("event[2].Kind = %q, want %q", events[2].Kind, coretypes.StreamText)
|
||||
}
|
||||
if events[3].Kind != coretypes.StreamResult {
|
||||
t.Errorf("event[3].Kind = %q, want %q", events[3].Kind, coretypes.StreamResult)
|
||||
}
|
||||
if events[3].Content != "The final answer" {
|
||||
t.Errorf("event[3].Content = %q, want %q", events[3].Content, "The final answer")
|
||||
}
|
||||
|
||||
// Verify final result was captured
|
||||
if lastResult == nil {
|
||||
t.Fatal("expected lastResult to be set")
|
||||
}
|
||||
if lastResult.Result != "The final answer" {
|
||||
t.Errorf("lastResult.Result = %q", lastResult.Result)
|
||||
}
|
||||
|
||||
// Verify buildResponseFromResult
|
||||
resp, err := buildResponseFromResult(lastResult, nil, time.Second, discardLog)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Content != "The final answer" {
|
||||
t.Errorf("resp.Content = %q", resp.Content)
|
||||
}
|
||||
if resp.Usage.InputTokens != 50 {
|
||||
t.Errorf("input_tokens = %d, want 50", resp.Usage.InputTokens)
|
||||
}
|
||||
if resp.FinishReason != "stop" {
|
||||
t.Errorf("finish_reason = %q, want %q", resp.FinishReason, "stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseFromResult_Error(t *testing.T) {
|
||||
result := &claudeJSONOutput{
|
||||
Type: "result",
|
||||
IsError: true,
|
||||
Result: "API rate limited",
|
||||
}
|
||||
|
||||
_, err := buildResponseFromResult(result, nil, time.Second, discardLog)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for IsError=true")
|
||||
}
|
||||
if !contains(err.Error(), "API rate limited") {
|
||||
t.Errorf("error = %q, should contain 'API rate limited'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildResponseFromResult_ExecError(t *testing.T) {
|
||||
result := &claudeJSONOutput{
|
||||
Type: "result",
|
||||
Result: "partial output",
|
||||
Usage: claudeUsage{InputTokens: 10, OutputTokens: 5},
|
||||
}
|
||||
|
||||
resp, err := buildResponseFromResult(result, errors.New("timeout"), time.Second, discardLog)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.FinishReason != "error" {
|
||||
t.Errorf("finish_reason = %q, want %q", resp.FinishReason, "error")
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
|
||||
Reference in New Issue
Block a user