fix(parser): improve text message handling and logging in Claude

- Added logic to buffer whitespace when no active text message is present, preventing unnecessary message group creation.
- Ensured proper closure of open text messages before executing new messages to maintain message integrity.
- Enhanced logging in the Stream and runStream methods to provide better visibility into execution flow and errors.
- Implemented shutdown logic to handle process termination gracefully after stream completion, addressing known issues with the Claude CLI.
This commit is contained in:
Max 2026-03-24 17:40:09 +08:00
parent c4696380f4
commit 6d57c99357
3 changed files with 39 additions and 2 deletions

View file

@ -326,6 +326,12 @@ func (p *streamParser) onContentBlockDelta(event map[string]any) (stopped bool)
if text == "" {
return false
}
// If there is no active text message and this delta is only
// whitespace, buffer it instead of opening a brand-new message
// group just for spaces/indentation between tool calls.
if !p.textActive && strings.TrimSpace(text) == "" {
return false
}
if p.ensureTextMessage() {
return true
}
@ -448,6 +454,12 @@ func (p *streamParser) handleUser(msg map[string]any) (stopped bool) {
continue
}
// Close any open text message before opening an execute message,
// otherwise textActive stays true while currentGroupID gets
// overwritten by the execute message lifecycle, causing subsequent
// text chunks to be emitted without a message_id.
p.closeTextMessage()
toolUseID, _ := ci["tool_use_id"].(string)
content := ci["content"]
isError, _ := ci["is_error"].(bool)

View file

@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"github.com/yaoapp/gou/connector"
@ -52,7 +51,7 @@ func (r *ClaudeRunner) Prepare(ctx context.Context, req *types.PrepareRequest) e
src := "local:///" + req.SkillsDir
dst := prefix + "/skills"
if _, err := ws.Copy(src, dst); err != nil {
fmt.Fprintf(os.Stderr, "[claude] warn: copy skills %s -> %s: %v\n", src, dst, err)
r.logger.Warn("copy skills %s -> %s: %v", src, dst, err)
}
}
}
@ -115,6 +114,10 @@ func (r *ClaudeRunner) Stream(ctx context.Context, req *types.StreamRequest, han
completed, err := sess.runStream(handler)
r.lastCompleted = completed
r.logger.Debug("Stream: runStream returned completed=%v err=%v", completed, err)
if completed {
sess.shutdown()
}
return err
}

View file

@ -59,6 +59,8 @@ func (s *session) runStream(handler message.StreamFunc) (completed bool, err err
parser := newStreamParser(handler)
parseErr := parser.parse(s.ctx, s.exec.Stdout)
s.logger.Debug("runStream: parse returned completed=%v parseErr=%v", parser.completed, parseErr)
if parser.completed {
s.logger.Info("claude stream completed normally")
return true, nil
@ -127,6 +129,26 @@ func (s *session) watchCancel() func() {
return func() { close(done) }
}
// shutdown terminates the claude process after a normal stream completion.
//
// Claude CLI's stream-json mode has a known bug where the process hangs
// indefinitely after emitting the "result" event (anthropics/claude-code#25629).
// There is no graceful exit mechanism, so we must kill the process externally.
//
// We send SIGKILL (-9) to processes named exactly "claude". SIGKILL cannot be
// caught, so Claude CLI has no opportunity to run its SIGTERM handler which
// would actively terminate child processes (web servers, etc.). Those children
// survive because they run in separate process groups/sessions.
func (s *session) shutdown() {
s.logger.Info("shutting down completed claude exec session")
killCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
result, err := s.computer.Exec(killCtx, []string{"sh", "-c", "pkill -9 -x claude || true"})
s.logger.Debug("shutdown: pkill -9 -x claude exitCode=%d err=%v", result.ExitCode, err)
s.exec.Cancel()
}
// waitForExit waits for the Claude process to exit with timeout protection.
// This fixes the old code's issue where Wait() could block forever.
func (s *session) waitForExit(parseErr error) error {