feat(ai): 补齐 Cursor 与 CodeBuddy 会话态聊天链路

- 新增 SessionChatProvider 接口,补齐非流式对话的会话态复用能力
- 为 Cursor Agent 和 CodeBuddy CLI 同步实现流式与非流式会话续接及状态持久化
- CustomProvider 补充会话态透传,统一 custom provider 的会话复用行为
- Service 新增 AIChatSendInSession,聊天主链路非流式回退改走带 session 的发送接口
- 保留原 AIChatSend 无状态语义,避免标题生成和记忆压缩污染主会话上下文
- 补充前后端定向测试,覆盖会话恢复、续接发送和前端回退分流
This commit is contained in:
Syngnat
2026-06-18 13:35:08 +08:00
parent b588235b62
commit 06dd9507ee
12 changed files with 1392 additions and 137 deletions

View File

@@ -24,6 +24,10 @@ type CodeBuddyCLIProvider struct {
config ai.ProviderConfig
}
type codebuddySessionState struct {
SessionID string `json:"sessionId,omitempty"`
}
// NewCodeBuddyCLIProvider 创建 CodeBuddyCLIProvider 实例。
func NewCodeBuddyCLIProvider(config ai.ProviderConfig) (Provider, error) {
return &CodeBuddyCLIProvider{config: config}, nil
@@ -42,8 +46,13 @@ func (p *CodeBuddyCLIProvider) Validate() error {
}
func (p *CodeBuddyCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*ai.ChatResponse, error) {
resp, _, err := p.ChatWithState(ctx, nil, req)
return resp, err
}
func (p *CodeBuddyCLIProvider) ChatWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest) (*ai.ChatResponse, json.RawMessage, error) {
if err := p.Validate(); err != nil {
return nil, err
return nil, nil, err
}
ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout)
@@ -51,18 +60,25 @@ func (p *CodeBuddyCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*a
commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath)
if err != nil {
return nil, err
return nil, nil, err
}
sessionState, err := parseCodeBuddySessionState(state)
if err != nil {
return nil, nil, err
}
prompt := buildPrompt(req.Messages)
args := []string{"-p", prompt, "--output-format", "json", "--no-session-persistence"}
args := []string{"-p", prompt, "--output-format", "json", "--enable-session-tracking"}
if strings.TrimSpace(p.config.Model) != "" {
args = append(args, "--model", strings.TrimSpace(p.config.Model))
}
if strings.TrimSpace(sessionState.SessionID) != "" {
args = append(args, "--resume", strings.TrimSpace(sessionState.SessionID))
}
cmd := codebuddyCommandContext(ctx, commandName, args...)
if err := p.setEnv(cmd); err != nil {
return nil, err
return nil, nil, err
}
requestLog := logAIUpstreamRequestStart(
@@ -80,27 +96,55 @@ func (p *CodeBuddyCLIProvider) Chat(ctx context.Context, req ai.ChatRequest) (*a
if err != nil {
if isClaudeCLITimeout(ctx, err) {
requestErr = fmt.Errorf("CodeBuddy CLI 执行超时(%s当前登录态、Base URL 或 API Key 可能没有返回有效响应", codebuddyCLIRequestTimeout)
return nil, requestErr
return nil, nil, requestErr
}
if exitErr, ok := err.(*exec.ExitError); ok {
requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %s", string(exitErr.Stderr))
return nil, requestErr
return nil, nil, requestErr
}
requestErr = fmt.Errorf("CodeBuddy CLI 执行失败: %w", err)
return nil, requestErr
return nil, nil, requestErr
}
resp, parseErr := parseCodeBuddyCLIChatOutput(output)
resp, nextSessionID, parseErr := parseCodeBuddyCLIChatOutput(output)
if parseErr != nil {
requestErr = parseErr
return nil, requestErr
return nil, nil, requestErr
}
return resp, nil
if strings.TrimSpace(nextSessionID) == "" {
nextSessionID = strings.TrimSpace(sessionState.SessionID)
}
nextState, err := marshalCodeBuddySessionState(nextSessionID)
if err != nil {
return nil, nil, err
}
return resp, nextState, nil
}
func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatRequest, callback func(ai.StreamChunk)) error {
_, err := p.ChatStreamWithState(ctx, nil, req, callback)
return err
}
func (p *CodeBuddyCLIProvider) ChatStreamWithState(ctx context.Context, state json.RawMessage, req ai.ChatRequest, callback func(ai.StreamChunk)) (json.RawMessage, error) {
sessionState, err := parseCodeBuddySessionState(state)
if err != nil {
return nil, err
}
sessionID, err := p.chatStreamWithSession(ctx, strings.TrimSpace(sessionState.SessionID), req, callback)
if err != nil {
return nil, err
}
if strings.TrimSpace(sessionID) == "" {
sessionID = strings.TrimSpace(sessionState.SessionID)
}
return marshalCodeBuddySessionState(sessionID)
}
func (p *CodeBuddyCLIProvider) chatStreamWithSession(ctx context.Context, resumeSessionID string, req ai.ChatRequest, callback func(ai.StreamChunk)) (string, error) {
if err := p.Validate(); err != nil {
return err
return "", err
}
ctx, cancel := ensureClaudeCLITimeout(ctx, codebuddyCLIRequestTimeout)
@@ -108,18 +152,21 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
commandName, err := resolveCodeBuddyCLICommand(codebuddyLookPath)
if err != nil {
return err
return "", err
}
prompt := buildPrompt(req.Messages)
args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--no-session-persistence"}
args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--enable-session-tracking"}
if strings.TrimSpace(p.config.Model) != "" {
args = append(args, "--model", strings.TrimSpace(p.config.Model))
}
if strings.TrimSpace(resumeSessionID) != "" {
args = append(args, "--resume", strings.TrimSpace(resumeSessionID))
}
cmd := codebuddyCommandContext(ctx, commandName, args...)
if err := p.setEnv(cmd); err != nil {
return err
return "", err
}
requestLog := logAIUpstreamRequestStart(
@@ -138,7 +185,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
stdout, err := cmd.StdoutPipe()
if err != nil {
requestErr = fmt.Errorf("创建 stdout 管道失败: %w", err)
return requestErr
return "", requestErr
}
var stderrBuf bytes.Buffer
@@ -146,7 +193,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
if err := cmd.Start(); err != nil {
requestErr = fmt.Errorf("启动 CodeBuddy CLI 失败: %w", err)
return requestErr
return "", requestErr
}
if cmd.Process != nil {
@@ -155,6 +202,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
currentSessionID := strings.TrimSpace(resumeSessionID)
for scanner.Scan() {
line := scanner.Text()
@@ -167,6 +215,9 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
logger.Warnf("CodeBuddyCLI 忽略非 JSON 输出requestId=%s line=%s", requestLog.id, RedactAIUpstreamLogText(line))
continue
}
if strings.TrimSpace(event.SessionID) != "" {
currentSessionID = strings.TrimSpace(event.SessionID)
}
switch event.Type {
case "system":
@@ -178,7 +229,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
return nil
return "", nil
}
}
case "assistant":
@@ -186,7 +237,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg)
_ = cmd.Wait()
return nil
return "", nil
}
if event.Message.Content != nil {
for _, block := range event.Message.Content {
@@ -208,17 +259,17 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg)
_ = cmd.Wait()
return nil
return "", nil
}
callback(ai.StreamChunk{Done: true})
_ = cmd.Wait()
return nil
return currentSessionID, nil
case "error":
errMsg, _ := extractCodeBuddyCLIEventError(event)
callback(ai.StreamChunk{Error: errMsg, Done: true})
requestErr = fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg)
_ = cmd.Wait()
return nil
return "", nil
}
}
@@ -231,7 +282,7 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
Error: requestErr.Error(),
Done: true,
})
return nil
return "", nil
}
if waitErr != nil {
@@ -241,11 +292,38 @@ func (p *CodeBuddyCLIProvider) ChatStream(ctx context.Context, req ai.ChatReques
}
requestErr = fmt.Errorf("%s", errMsg)
callback(ai.StreamChunk{Error: errMsg, Done: true})
return nil
return "", nil
}
callback(ai.StreamChunk{Done: true})
return nil
return currentSessionID, nil
}
func parseCodeBuddySessionState(state json.RawMessage) (codebuddySessionState, error) {
trimmed := bytes.TrimSpace(state)
if len(trimmed) == 0 {
return codebuddySessionState{}, nil
}
var sessionState codebuddySessionState
if err := json.Unmarshal(trimmed, &sessionState); err != nil {
return codebuddySessionState{}, fmt.Errorf("解析 CodeBuddy 会话状态失败: %w", err)
}
sessionState.SessionID = strings.TrimSpace(sessionState.SessionID)
return sessionState, nil
}
func marshalCodeBuddySessionState(sessionID string) (json.RawMessage, error) {
sessionID = strings.TrimSpace(sessionID)
if sessionID == "" {
return nil, nil
}
payload, err := json.Marshal(codebuddySessionState{SessionID: sessionID})
if err != nil {
return nil, fmt.Errorf("序列化 CodeBuddy 会话状态失败: %w", err)
}
return json.RawMessage(payload), nil
}
func resolveCodeBuddyCLICommand(lookPath func(string) (string, error)) (string, error) {
@@ -280,10 +358,10 @@ func buildCodeBuddyCLIRequestLogBody(outputFormat string, commandName string, ar
}
}
func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, error) {
func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, string, error) {
trimmed := bytes.TrimSpace(output)
if len(trimmed) == 0 {
return &ai.ChatResponse{}, nil
return &ai.ChatResponse{}, "", nil
}
var events []cliStreamEvent
@@ -296,20 +374,24 @@ func parseCodeBuddyCLIChatOutput(output []byte) (*ai.ChatResponse, error) {
return buildCodeBuddyCLIResponseFromEvents([]cliStreamEvent{event})
}
return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, nil
return &ai.ChatResponse{Content: strings.TrimSpace(string(output))}, "", nil
}
func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatResponse, error) {
func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatResponse, string, error) {
parts := make([]string, 0, len(events))
resultText := ""
sessionID := ""
for _, event := range events {
if errMsg, hasError := extractCodeBuddyCLIEventError(event); hasError {
return nil, fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg)
return nil, "", fmt.Errorf("CodeBuddy CLI 返回错误: %s", errMsg)
}
if strings.TrimSpace(event.Result) != "" {
resultText = strings.TrimSpace(event.Result)
}
if strings.TrimSpace(event.SessionID) != "" {
sessionID = strings.TrimSpace(event.SessionID)
}
for _, block := range event.Message.Content {
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
parts = append(parts, block.Text)
@@ -318,12 +400,12 @@ func buildCodeBuddyCLIResponseFromEvents(events []cliStreamEvent) (*ai.ChatRespo
}
if resultText != "" {
return &ai.ChatResponse{Content: resultText}, nil
return &ai.ChatResponse{Content: resultText}, sessionID, nil
}
if len(parts) > 0 {
return &ai.ChatResponse{Content: strings.Join(parts, "")}, nil
return &ai.ChatResponse{Content: strings.Join(parts, "")}, sessionID, nil
}
return &ai.ChatResponse{}, nil
return &ai.ChatResponse{}, sessionID, nil
}
func (p *CodeBuddyCLIProvider) setEnv(cmd *exec.Cmd) error {