diff --git a/docs/context-compact.md b/docs/context-compact.md index 7f6fec7a..92a92c99 100644 --- a/docs/context-compact.md +++ b/docs/context-compact.md @@ -4,8 +4,8 @@ ## 概览 -- runtime 当前仅接入手动触发的 compact,不包含自动 compact。 -- `internal/context/compact` 已支持 `manual` 与 `reactive` 两种 mode,供 runtime 后续在 provider 上下文过长错误场景接入调用。 +- runtime 已接入手动 compact、基于 token 阈值的自动 compact,以及 provider 上下文过长后的 `reactive` compact 自动恢复。 +- `internal/context/compact` 支持 `manual` 与 `reactive` 两种 mode。 - 用户通过 `/compact` 对当前会话执行一次上下文压缩。 - compact 前会先写入完整 transcript,随后生成并校验 compact summary,再回写会话消息。 @@ -69,7 +69,12 @@ context: 3. 生成并校验 `[compact_summary]`。 4. 返回压缩后的消息与 transcript 元信息。 -当前 runtime 主链尚未自动调用 `reactive` mode;后续接入时可继续复用现有 compact 事件,并通过 `trigger_mode=reactive` 区分。 +当 provider 返回“上下文过长”错误时,runtime 会: + +1. 识别 provider 归一化后的 typed error,必要时回退到错误文本匹配。 +2. 触发一次 `compact.Run(mode=reactive)`。 +3. 继续复用 `compact_start`、`compact_done`、`compact_error` 事件,并通过 `trigger_mode=reactive` 区分来源。 +4. 每次 `Run()` 最多只执行一次 reactive 重试,避免无限循环。 ## 摘要协议 @@ -106,7 +111,7 @@ constraints: ## 事件 -manual compact 相关 runtime 事件包括: +compact 相关 runtime 事件包括: - `compact_start` - `compact_done` diff --git a/docs/runtime-provider-event-flow.md b/docs/runtime-provider-event-flow.md index f8944d62..060d6c52 100644 --- a/docs/runtime-provider-event-flow.md +++ b/docs/runtime-provider-event-flow.md @@ -10,6 +10,9 @@ - `tool_result` - `error` - `token_usage` +- `compact_start` +- `compact_done` +- `compact_error` ## ReAct 主循环 @@ -18,10 +21,12 @@ 3. 读取最新配置快照。 4. 解析当前 provider 配置并构建 provider 实例。 5. 调用 `context.Builder` 生成本轮请求使用的 `system prompt` 和消息上下文。 -6. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。 -7. 保存 assistant 完整回复。 -8. 执行返回的工具调用,并保存每一个工具结果。 -9. 如果仍需继续推理,则进入下一轮;否则结束。 +6. 如命中 token 阈值自动压缩建议,则先执行一次 compact,再继续构造请求。 +7. 调用 `Provider.Chat`,并把流式事件桥接给 TUI。 +8. 如 provider 返回“上下文过长”错误,则触发一次 `reactive` compact,并仅重试一次当前请求。 +9. 保存 assistant 完整回复。 +10. 执行返回的工具调用,并保存每一个工具结果。 +11. 如果仍需继续推理,则进入下一轮;否则结束。 ### Context Builder 输入与职责 diff --git a/internal/context/compact/runner.go b/internal/context/compact/runner.go index 6ae8dc9c..dffa4bcd 100644 --- a/internal/context/compact/runner.go +++ b/internal/context/compact/runner.go @@ -18,6 +18,8 @@ type Mode string const ( // ModeManual runs the explicit user-triggered compact flow. ModeManual Mode = "manual" + // ModeAuto runs the token-threshold-triggered compact flow. + ModeAuto Mode = "auto" // ModeReactive runs the provider-error-triggered compact flow. ModeReactive Mode = "reactive" ) @@ -111,7 +113,7 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) { return Result{}, err } - if input.Mode != ModeManual && input.Mode != ModeReactive { + if input.Mode != ModeManual && input.Mode != ModeAuto && input.Mode != ModeReactive { return Result{}, fmt.Errorf("compact: unsupported mode %q", input.Mode) } diff --git a/internal/context/compact/runner_test.go b/internal/context/compact/runner_test.go index 4ab50825..5be1ef31 100644 --- a/internal/context/compact/runner_test.go +++ b/internal/context/compact/runner_test.go @@ -185,6 +185,54 @@ func TestReactiveCompactUsesKeepRecentAndReportsReactiveMode(t *testing.T) { } } +func TestAutoCompactUsesManualStrategyAndReportsAutoMode(t *testing.T) { + t.Parallel() + + generator := &stubSummaryGenerator{summary: validSemanticSummary()} + runner := NewRunner(generator) + home := t.TempDir() + runner.userHomeDir = func() (string, error) { return home, nil } + + messages := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "old requirement"}, + {Role: providertypes.RoleAssistant, Content: "old answer"}, + {Role: providertypes.RoleUser, Content: "middle request"}, + {Role: providertypes.RoleAssistant, Content: "middle answer"}, + {Role: providertypes.RoleUser, Content: "recent request"}, + {Role: providertypes.RoleAssistant, Content: "recent answer"}, + } + + result, err := runner.Run(context.Background(), Input{ + Mode: ModeAuto, + SessionID: "session-auto", + Workdir: t.TempDir(), + Messages: messages, + Config: config.CompactConfig{ + ManualStrategy: config.CompactManualStrategyKeepRecent, + ManualKeepRecentMessages: 4, + MaxSummaryChars: 1200, + }, + }) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if !result.Applied { + t.Fatalf("expected auto compact applied") + } + if result.Metrics.TriggerMode != string(ModeAuto) { + t.Fatalf("expected trigger mode %q, got %q", ModeAuto, result.Metrics.TriggerMode) + } + if len(generator.calls) != 1 { + t.Fatalf("expected generator to run once, got %d", len(generator.calls)) + } + if generator.calls[0].Mode != ModeAuto { + t.Fatalf("expected summary input mode %q, got %q", ModeAuto, generator.calls[0].Mode) + } + if generator.calls[0].Config.ManualStrategy != config.CompactManualStrategyKeepRecent { + t.Fatalf("expected auto compact to retain manual strategy, got %q", generator.calls[0].Config.ManualStrategy) + } +} + func TestManualCompactKeepRecentProtectsLatestExplicitUserInstruction(t *testing.T) { t.Parallel() diff --git a/internal/context/prompt.go b/internal/context/prompt.go index d480e5bf..dc32245b 100644 --- a/internal/context/prompt.go +++ b/internal/context/prompt.go @@ -16,6 +16,8 @@ var defaultPromptSections = []promptSection{ { title: "Tool Usage", content: "- Use tools when they reduce uncertainty or are required to complete the task safely.\n" + + "- For risky operations, call the relevant tool first and let the runtime permission layer decide ask/allow/deny.\n" + + "- Do not self-reject a user-requested operation before attempting the proper tool call and permission flow.\n" + "- Stay within the current workspace unless the user clearly asks for something else.\n" + "- Do not claim work is done unless the needed files, commands, or verification actually succeeded.", }, diff --git a/internal/context/prompt_test.go b/internal/context/prompt_test.go index df9c3fd7..1eb0b729 100644 --- a/internal/context/prompt_test.go +++ b/internal/context/prompt_test.go @@ -1,6 +1,9 @@ package context -import "testing" +import ( + "strings" + "testing" +) func TestDefaultSystemPromptSectionsReturnsCachedSections(t *testing.T) { t.Parallel() @@ -90,3 +93,25 @@ func TestComposeSystemPromptSkipsEmptySections(t *testing.T) { t.Fatalf("composeSystemPrompt() = %q, want %q", got, want) } } + +func TestDefaultToolUsagePromptEncouragesAskFlow(t *testing.T) { + t.Parallel() + + sections := defaultSystemPromptSections() + var toolUsage string + for _, section := range sections { + if section.title == "Tool Usage" { + toolUsage = section.content + break + } + } + if toolUsage == "" { + t.Fatalf("expected Tool Usage section to exist") + } + if !strings.Contains(toolUsage, "permission layer") { + t.Fatalf("expected Tool Usage to mention permission layer, got %q", toolUsage) + } + if !strings.Contains(toolUsage, "Do not self-reject") { + t.Fatalf("expected Tool Usage to discourage self-reject, got %q", toolUsage) + } +} diff --git a/internal/provider/errors.go b/internal/provider/errors.go index daff0da6..8107f190 100644 --- a/internal/provider/errors.go +++ b/internal/provider/errors.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "strings" ) // 通用领域错误。 @@ -20,17 +21,41 @@ var ( type ProviderErrorCode string const ( - ErrorCodeAuthFailed ProviderErrorCode = "auth_failed" // 认证失败(401) - ErrorCodeForbidden ProviderErrorCode = "forbidden" // 权限不足(403) - ErrorCodeNotFound ProviderErrorCode = "not_found" // 资源不存在(404) - ErrorCodeClient ProviderErrorCode = "client_error" // 客户端请求错误(4xx,排除上述分类) - ErrorCodeRateLimit ProviderErrorCode = "rate_limited" // 限流(429) - ErrorCodeServer ProviderErrorCode = "server_error" // 服务端错误(5xx) - ErrorCodeTimeout ProviderErrorCode = "timeout" // 超时 - ErrorCodeNetwork ProviderErrorCode = "network_error" // 网络错误(连接拒绝、DNS 失败等) - ErrorCodeUnknown ProviderErrorCode = "unknown" // 未知错误 + ErrorCodeAuthFailed ProviderErrorCode = "auth_failed" // 认证失败(401) + ErrorCodeForbidden ProviderErrorCode = "forbidden" // 权限不足(403) + ErrorCodeNotFound ProviderErrorCode = "not_found" // 资源不存在(404) + ErrorCodeClient ProviderErrorCode = "client_error" // 客户端请求错误(4xx,排除上述分类) + ErrorCodeRateLimit ProviderErrorCode = "rate_limited" // 限流(429) + ErrorCodeServer ProviderErrorCode = "server_error" // 服务端错误(5xx) + ErrorCodeTimeout ProviderErrorCode = "timeout" // 超时 + ErrorCodeNetwork ProviderErrorCode = "network_error" // 网络错误(连接拒绝、DNS 失败等) + ErrorCodeContextTooLong ProviderErrorCode = "context_too_long" // 上下文超出模型窗口 + ErrorCodeUnknown ProviderErrorCode = "unknown" // 未知错误 ) +var contextTooLongFragments = []string{ + "context length", + "context_length_exceeded", + "context window", + "maximum context length", + "maximum prompt length", + "prompt is too long", +} + +var contextTokenFragments = []string{ + "requested too many tokens", + "too many tokens", +} + +var contextTokenHints = []string{ + "context", + "prompt", + "message", + "input", + "history", + "window", +} + // ProviderError 是 provider 层的领域错误类型。 type ProviderError struct { StatusCode int // HTTP 状态码,0 表示非 HTTP 错误(如网络超时) @@ -78,6 +103,13 @@ func classifyStatus(statusCode int) ProviderErrorCode { // NewProviderErrorFromStatus 根据 HTTP 状态码和消息构造 ProviderError。 func NewProviderErrorFromStatus(statusCode int, message string) *ProviderError { code := classifyStatus(statusCode) + // Only elevate to context_too_long for generic client errors (e.g. 400/413). + // Do not override specific classifications such as rate_limited (429) even if + // the message contains token-count fragments, which would mis-route throttling + // errors into the reactive-compact path. + if code == ErrorCodeClient && matchesContextTooLong(message) { + code = ErrorCodeContextTooLong + } return &ProviderError{ StatusCode: statusCode, Code: code, @@ -105,3 +137,54 @@ func NewTimeoutProviderError(message string) *ProviderError { Retryable: true, // 超时默认可重试 } } + +// IsContextTooLong 判断 provider 错误是否表示请求上下文超出模型窗口。 +// 优先识别 typed error,必要时再回退到消息文本匹配,兼容不同厂商或额外包装层。 +// 已被归类为 rate_limited (429) 的错误不会因文本片段而被误判为 context_too_long。 +func IsContextTooLong(err error) bool { + if err == nil { + return false + } + + var pErr *ProviderError + if errors.As(err, &pErr) { + if pErr.Code == ErrorCodeContextTooLong { + return true + } + // Skip text fallback for errors that are already classified as a specific + // non-context error (e.g. rate_limited). Token-count fragments in 429 + // messages must not route the runtime into the reactive-compact path. + if pErr.Code == ErrorCodeRateLimit { + return false + } + if matchesContextTooLong(pErr.Message) { + return true + } + } + + return matchesContextTooLong(err.Error()) +} + +// matchesContextTooLong 统一收敛常见“上下文过长”报错片段,减少 runtime 对厂商文案的感知。 +func matchesContextTooLong(message string) bool { + normalized := strings.ToLower(strings.TrimSpace(message)) + if normalized == "" { + return false + } + for _, fragment := range contextTooLongFragments { + if strings.Contains(normalized, fragment) { + return true + } + } + for _, fragment := range contextTokenFragments { + if !strings.Contains(normalized, fragment) { + continue + } + for _, hint := range contextTokenHints { + if strings.Contains(normalized, hint) { + return true + } + } + } + return false +} diff --git a/internal/provider/errors_test.go b/internal/provider/errors_test.go index e6e9ac4d..54ddf0a2 100644 --- a/internal/provider/errors_test.go +++ b/internal/provider/errors_test.go @@ -121,6 +121,27 @@ func TestNewProviderErrorFromStatus(t *testing.T) { if err.Retryable { t.Fatalf("expected 401 to not be retryable") } + + err = NewProviderErrorFromStatus(400, "This model's maximum context length is 128000 tokens.") + if err.Code != ErrorCodeContextTooLong { + t.Fatalf("expected code %q, got %q", ErrorCodeContextTooLong, err.Code) + } + + // 429 with token-count message must stay rate_limited, not context_too_long. + err = NewProviderErrorFromStatus(429, "requested too many tokens for this minute") + if err.Code != ErrorCodeRateLimit { + t.Fatalf("429 with token-count message: expected code %q, got %q", ErrorCodeRateLimit, err.Code) + } + + err = NewProviderErrorFromStatus(400, "requested too many tokens in the prompt context window") + if err.Code != ErrorCodeContextTooLong { + t.Fatalf("expected contextual token-count message to map to %q, got %q", ErrorCodeContextTooLong, err.Code) + } + + err = NewProviderErrorFromStatus(400, "requested too many tokens for max_tokens") + if err.Code != ErrorCodeClient { + t.Fatalf("expected output-token validation message to stay %q, got %q", ErrorCodeClient, err.Code) + } } func TestNewNetworkProviderError(t *testing.T) { @@ -170,3 +191,79 @@ func TestProviderError_As(t *testing.T) { t.Fatalf("expected retryable") } } + +func TestIsContextTooLong(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + want bool + }{ + { + name: "typed provider error", + err: &ProviderError{ + StatusCode: 400, + Code: ErrorCodeContextTooLong, + Message: "maximum context length exceeded", + }, + want: true, + }, + { + name: "wrapped provider message fallback", + err: fmt.Errorf("wrapped: %w", &ProviderError{ + StatusCode: 400, + Code: ErrorCodeClient, + Message: "prompt is too long for this model", + }), + want: true, + }, + { + name: "plain text fallback", + err: errors.New("context window exceeded for model"), + want: true, + }, + { + name: "non context error", + err: errors.New("invalid api key"), + want: false, + }, + { + name: "rate limited with token-count message is not context_too_long", + err: &ProviderError{ + StatusCode: 429, + Code: ErrorCodeRateLimit, + Message: "requested too many tokens for this minute", + }, + want: false, + }, + { + name: "output token validation message is not context_too_long", + err: &ProviderError{ + StatusCode: 400, + Code: ErrorCodeClient, + Message: "requested too many tokens for max_tokens", + }, + want: false, + }, + { + name: "contextual token-count message is context_too_long", + err: &ProviderError{ + StatusCode: 400, + Code: ErrorCodeClient, + Message: "requested too many tokens for prompt context window", + }, + want: true, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := IsContextTooLong(tt.err); got != tt.want { + t.Fatalf("IsContextTooLong() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/provider/openai/events.go b/internal/provider/openai/events.go index 50e13338..8e5f9024 100644 --- a/internal/provider/openai/events.go +++ b/internal/provider/openai/events.go @@ -33,7 +33,20 @@ func emitToolCallDelta(ctx context.Context, events chan<- providertypes.StreamEv // emitMessageDone 发送消息完成事件。 func emitMessageDone(ctx context.Context, events chan<- providertypes.StreamEvent, finishReason string, usage *providertypes.Usage) error { - return emitStreamEvent(ctx, events, providertypes.NewMessageDoneStreamEvent(finishReason, usage)) + event := providertypes.NewMessageDoneStreamEvent(finishReason, usage) + if ctx == nil || ctx.Err() == nil { + return emitStreamEvent(ctx, events, event) + } + if events == nil { + return nil + } + + select { + case events <- event: + return nil + default: + return nil + } } // emitStreamEvent 通过 channel 安全发送流式事件,支持上下文取消和 nil channel 保护。 diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index d16edc56..e8e73442 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -555,6 +555,78 @@ func TestConsumeStream_ContextCancellation(t *testing.T) { } } +func TestConsumeStream_ContextCancellationOnReadErrorReturnsCanceled(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + body := &cancelThenErrorReader{cancel: cancel, err: io.ErrClosedPipe} + err = p.consumeStream(ctx, body, make(chan providertypes.StreamEvent, 1)) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestConsumeStream_ContextCancellationAtEOFWithoutDoneReturnsCanceled(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + sseData := `data: {"id":"a","choices":[{"delta":{"content":"hello"}}]} + +` + body := &cancelOnEOFReader{ + reader: strings.NewReader(sseData), + cancel: cancel, + } + events := make(chan providertypes.StreamEvent, 8) + + err = p.consumeStream(ctx, body, events) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestConsumeStream_DoneThenCancellationStillFinishes(t *testing.T) { + t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") + + p, err := New(resolvedConfig("", "")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + body := &cancelAfterDoneReader{ + payload: []byte("data: [DONE]\n"), + cancel: cancel, + err: io.ErrClosedPipe, + } + events := make(chan providertypes.StreamEvent, 4) + + err = p.consumeStream(ctx, body, events) + if err != nil { + t.Fatalf("expected completed stream after [DONE], got %v", err) + } + + var foundDone bool + for _, evt := range drainStreamEvents(events) { + if evt.Type == providertypes.StreamEventMessageDone { + foundDone = true + } + } + if !foundDone { + t.Fatal("expected message_done event after cancellation race post-[DONE]") + } +} + func TestConsumeStream_FinishReasonAccumulation(t *testing.T) { t.Setenv(config.OpenAIDefaultAPIKeyEnv, "test-key") @@ -870,6 +942,28 @@ func TestParseError_InvalidJSONBody(t *testing.T) { } } +func TestParseError_ClassifiesContextTooLong(t *testing.T) { + t.Parallel() + + p, err := New(resolvedConfig(config.OpenAIDefaultBaseURL, config.OpenAIDefaultModel)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + resp := &http.Response{ + Status: "400 Bad Request", + StatusCode: 400, + Body: ioNopCloser(`{"error":{"message":"This model's maximum context length is 128000 tokens. However, your messages resulted in 140000 tokens."}}`), + } + err = p.parseError(resp) + if err == nil { + t.Fatal("expected context too long error") + } + if !provider.IsContextTooLong(err) { + t.Fatalf("expected parsed error to be classified as context too long, got %v", err) + } +} + // --- 原有保留的集成测试(保持兼容) --- func TestProviderChatConsumesSSEAndMergesToolCalls(t *testing.T) { @@ -1266,6 +1360,46 @@ type errReader struct{ err error } func (e *errReader) Read(_ []byte) (int, error) { return 0, e.err } +type cancelThenErrorReader struct { + cancel func() + err error +} + +func (r *cancelThenErrorReader) Read(_ []byte) (int, error) { + r.cancel() + return 0, r.err +} + +type cancelOnEOFReader struct { + reader io.Reader + cancel func() +} + +func (r *cancelOnEOFReader) Read(p []byte) (int, error) { + n, err := r.reader.Read(p) + if errors.Is(err, io.EOF) { + r.cancel() + } + return n, err +} + +type cancelAfterDoneReader struct { + payload []byte + cancel func() + err error + read bool +} + +func (r *cancelAfterDoneReader) Read(p []byte) (int, error) { + if !r.read { + r.read = true + n := copy(p, r.payload) + return n, nil + } + r.cancel() + return 0, r.err +} + type failingReadCloser struct{ err error } func (f *failingReadCloser) Read(_ []byte) (int, error) { return 0, f.err } diff --git a/internal/provider/openai/response.go b/internal/provider/openai/response.go index 5026a15d..ad9fe23d 100644 --- a/internal/provider/openai/response.go +++ b/internal/provider/openai/response.go @@ -13,7 +13,7 @@ import ( providertypes "neo-code/internal/provider/types" ) -// consumeStream 消费 SSE 响应流,使用有界读取器防止缓冲区溢出。 +// consumeStream 消费 SSE 响应流,并在 [DONE] 或 message_done 时完成收尾。 func (p *Provider) consumeStream( ctx context.Context, body io.Reader, @@ -30,7 +30,7 @@ func (p *Provider) consumeStream( dataLines := make([]string, 0, 4) - // processChunk 解析单个 SSE data payload,发送事件。 + // processChunk 解析单个 SSE data payload,并发出增量事件。 processChunk := func(payload string) error { if strings.TrimSpace(payload) == "[DONE]" { done = true @@ -66,23 +66,40 @@ func (p *Provider) consumeStream( return nil } - // finishStream 统一的流结束处理:发送 message_done 事件。 + // finishStream 统一输出 message_done 收尾事件。 finishStream := func() error { log.Printf("[DEBUG-STREAM] finishStream called: finishReason=%q, done=%v", finishReason, done) return emitMessageDone(ctx, events, finishReason, &usage) } + // flushPendingData 刷新积累的 data 行,保证多行 data payload 正确拼接。 flushPendingData := func() error { defer func() { dataLines = dataLines[:0] }() return flushDataLines(dataLines, processChunk) } for { - line, err := reader.ReadLine() + // 每次读取前优先响应上下文取消,避免取消请求被误判为流中断。 + // 一旦收到 [DONE],后续取消不应覆盖已完成的流收尾。 + if !done { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + } + line, err := reader.ReadLine() if err != nil && !errors.Is(err, io.EOF) { - // 非 EOF 的读取错误:先刷新缓冲的 data 行,再包装为流中断, - // 避免中断前最后一段数据丢失。 + if done { + if flushErr := flushPendingData(); flushErr != nil { + return flushErr + } + return finishStream() + } + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } if flushErr := flushPendingData(); flushErr != nil { return flushErr } @@ -90,12 +107,9 @@ func (p *Provider) consumeStream( } trimmed := line - switch { case strings.HasPrefix(trimmed, "data:"): data := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:")) - // data: [DONE] 需要立即处理:先刷新已缓冲的 data 行,再标记结束, - // 避免与前面的合法 JSON 拼接后导致 json.Unmarshal 失败。 if data == "[DONE]" { if flushErr := flushPendingData(); flushErr != nil { return flushErr @@ -116,10 +130,12 @@ func (p *Provider) consumeStream( } if errors.Is(err, io.EOF) { - // [DEBUG] 流 EOF 时打印关键状态,用于诊断截断原因 log.Printf("[DEBUG-STREAM] EOF reached: done=%v, finishReason=%q, totalRead=%d, toolCallCount=%d", done, finishReason, reader.totalRead, len(toolCalls)) if !done { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } log.Printf("[DEBUG-STREAM] WARNING: stream ended WITHOUT [DONE] marker — treating as interruption") if flushErr := flushPendingData(); flushErr != nil { return flushErr @@ -134,7 +150,7 @@ func (p *Provider) consumeStream( } } -// extractStreamUsage 从 OpenAI usage 响应提取并覆盖累积的 token 统计。 +// extractStreamUsage 将 OpenAI usage 响应覆盖到累计 token 统计。 func extractStreamUsage(usage *providertypes.Usage, raw *openAIUsage) { if raw == nil { return diff --git a/internal/runtime/compact.go b/internal/runtime/compact.go index 26487f33..3a9e426d 100644 --- a/internal/runtime/compact.go +++ b/internal/runtime/compact.go @@ -64,7 +64,7 @@ func (s *Service) Compact(ctx context.Context, input CompactInput) (CompactResul return CompactResult{}, err } - session, result, err := s.runCompactForSession(ctx, input.RunID, session, cfg, true) + session, result, err := s.runCompactForSession(ctx, input.RunID, session, cfg, contextcompact.ModeManual, true) if err != nil { return CompactResult{}, err } @@ -86,6 +86,7 @@ func (s *Service) runCompactForSession( runID string, session agentsession.Session, cfg config.Config, + mode contextcompact.Mode, failOnError bool, ) (agentsession.Session, contextcompact.Result, error) { runner := s.compactRunner @@ -94,7 +95,7 @@ func (s *Service) runCompactForSession( runner, err = s.defaultCompactRunner(session, cfg) if err != nil { s.emit(ctx, EventCompactError, runID, session.ID, CompactErrorPayload{ - TriggerMode: string(contextcompact.ModeManual), + TriggerMode: string(mode), Message: err.Error(), }) if failOnError { @@ -105,18 +106,18 @@ func (s *Service) runCompactForSession( } originalMessages := append([]providertypes.Message(nil), session.Messages...) - s.emit(ctx, EventCompactStart, runID, session.ID, string(contextcompact.ModeManual)) + s.emit(ctx, EventCompactStart, runID, session.ID, string(mode)) result, err := runner.Run(ctx, contextcompact.Input{ - Mode: contextcompact.ModeManual, + Mode: mode, SessionID: session.ID, - Workdir: cfg.Workdir, + Workdir: effectiveSessionWorkdir(session.Workdir, cfg.Workdir), Messages: session.Messages, Config: cfg.Context.Compact, }) if err != nil { s.emit(ctx, EventCompactError, runID, session.ID, CompactErrorPayload{ - TriggerMode: string(contextcompact.ModeManual), + TriggerMode: string(mode), Message: err.Error(), }) if failOnError { @@ -127,10 +128,15 @@ func (s *Service) runCompactForSession( if result.Applied { session.Messages = append([]providertypes.Message(nil), result.Messages...) + // Reset token totals now so the persisted session never carries stale high + // counts; if the follow-up provider call is canceled before its own save + // the next run will start from zero rather than immediately auto-compacting. + session.TokenInputTotal = 0 + session.TokenOutputTotal = 0 session.UpdatedAt = time.Now() if err := s.sessionStore.Save(ctx, &session); err != nil { s.emit(ctx, EventCompactError, runID, session.ID, CompactErrorPayload{ - TriggerMode: string(contextcompact.ModeManual), + TriggerMode: string(mode), Message: err.Error(), }) session.Messages = originalMessages @@ -146,7 +152,7 @@ func (s *Service) runCompactForSession( BeforeChars: result.Metrics.BeforeChars, AfterChars: result.Metrics.AfterChars, SavedRatio: result.Metrics.SavedRatio, - TriggerMode: string(contextcompact.ModeManual), + TriggerMode: string(mode), TranscriptID: result.TranscriptID, TranscriptPath: result.TranscriptPath, } @@ -155,6 +161,17 @@ func (s *Service) runCompactForSession( return session, result, nil } +// resetSessionTokenTotals 在 compact 成功后同步清零内存计数器与会话累计 token。 +func (s *Service) resetSessionTokenTotals(session *agentsession.Session) { + s.sessionInputTokens = 0 + s.sessionOutputTokens = 0 + if session == nil { + return + } + session.TokenInputTotal = 0 + session.TokenOutputTotal = 0 +} + // defaultCompactRunner 为手动 compact 选择摘要生成器并构造默认 runner。 func (s *Service) defaultCompactRunner(session agentsession.Session, cfg config.Config) (contextcompact.Runner, error) { resolvedProvider, model, err := resolveCompactProviderSelection(session, cfg) diff --git a/internal/runtime/events.go b/internal/runtime/events.go index 80117aec..97eadc6a 100644 --- a/internal/runtime/events.go +++ b/internal/runtime/events.go @@ -85,8 +85,8 @@ const ( // TokenUsagePayload carries token usage statistics for a single provider turn. type TokenUsagePayload struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` SessionInputTokens int `json:"session_input_tokens"` SessionOutputTokens int `json:"session_output_tokens"` -} \ No newline at end of file +} diff --git a/internal/runtime/permission.go b/internal/runtime/permission.go index 0ffe62ee..00a0fb76 100644 --- a/internal/runtime/permission.go +++ b/internal/runtime/permission.go @@ -100,8 +100,11 @@ func (s *Service) executeToolCallWithPermission(ctx context.Context, input permi Arguments: []byte(input.Call.Arguments), Workdir: input.Workdir, SessionID: input.SessionID, - EmitChunk: func(chunk []byte) { - s.emit(ctx, EventToolChunk, input.RunID, input.SessionID, string(chunk)) + EmitChunk: func(chunk []byte) error { + if err := ctx.Err(); err != nil { + return err + } + return s.emit(ctx, EventToolChunk, input.RunID, input.SessionID, string(chunk)) }, } diff --git a/internal/runtime/permission_test.go b/internal/runtime/permission_test.go index c82a9526..86513069 100644 --- a/internal/runtime/permission_test.go +++ b/internal/runtime/permission_test.go @@ -3,6 +3,7 @@ package runtime import ( "context" "errors" + "sync" "testing" "time" @@ -335,3 +336,187 @@ func TestResolvePermissionCanceledContext(t *testing.T) { t.Fatalf("expected context canceled, got %v", err) } } + +func TestExecuteToolCallWithPermissionReturnsContextCanceledFromEmitChunk(t *testing.T) { + t.Parallel() + + registry := tools.NewRegistry() + registry.Register(&stubTool{ + name: "filesystem_read_file", + executeFn: func(_ context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + if input.EmitChunk == nil { + t.Fatalf("expected EmitChunk callback") + } + if err := input.EmitChunk([]byte("stream-chunk")); !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled from emitter, got %v", err) + } + return tools.NewErrorResult(input.Name, "emit failed", "", nil), context.Canceled + }, + }) + + engine, err := security.NewStaticGateway(security.DecisionAllow, nil) + if err != nil { + t.Fatalf("new static gateway: %v", err) + } + toolManager, err := tools.NewManager(registry, engine, nil) + if err != nil { + t.Fatalf("new tool manager: %v", err) + } + + service := NewWithFactory( + newRuntimeConfigManager(t), + toolManager, + newMemoryStore(), + &scriptedProviderFactory{provider: &scriptedProvider{}}, + nil, + ) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, execErr := service.executeToolCallWithPermission(ctx, permissionExecutionInput{ + RunID: "run-canceled", + SessionID: "session-canceled", + Call: providertypes.ToolCall{ + ID: "call-canceled", + Name: "filesystem_read_file", + Arguments: `{"path":"README.md"}`, + }, + ToolTimeout: time.Second, + }) + if !errors.Is(execErr, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", execErr) + } +} + +type doneSignalContext struct { + context.Context + doneCalled chan struct{} + once sync.Once +} + +// Done 在 runtime.emit 进入阻塞发送分支时发出信号,便于测试精确控制取消时机。 +func (c *doneSignalContext) Done() <-chan struct{} { + c.once.Do(func() { + close(c.doneCalled) + }) + return c.Context.Done() +} + +func TestExecuteToolCallWithPermissionDoesNotRecheckContextAfterSuccessfulEmit(t *testing.T) { + t.Parallel() + + var cancel context.CancelFunc + registry := tools.NewRegistry() + registry.Register(&stubTool{ + name: "filesystem_read_file", + executeFn: func(_ context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + if input.EmitChunk == nil { + t.Fatalf("expected EmitChunk callback") + } + if err := input.EmitChunk([]byte("stream-chunk")); err != nil { + t.Fatalf("expected successful emit, got %v", err) + } + cancel() + return tools.ToolResult{Name: input.Name, Content: "ok"}, nil + }, + }) + + engine, err := security.NewStaticGateway(security.DecisionAllow, nil) + if err != nil { + t.Fatalf("new static gateway: %v", err) + } + toolManager, err := tools.NewManager(registry, engine, nil) + if err != nil { + t.Fatalf("new tool manager: %v", err) + } + + service := NewWithFactory( + newRuntimeConfigManager(t), + toolManager, + newMemoryStore(), + &scriptedProviderFactory{provider: &scriptedProvider{}}, + nil, + ) + + ctx, cancel := context.WithCancel(context.Background()) + service.events = make(chan RuntimeEvent, 1) + + result, execErr := service.executeToolCallWithPermission(ctx, permissionExecutionInput{ + RunID: "run-successful-emit", + SessionID: "session-successful-emit", + Call: providertypes.ToolCall{ + ID: "call-successful-emit", + Name: "filesystem_read_file", + Arguments: `{"path":"README.md"}`, + }, + ToolTimeout: time.Second, + }) + if execErr != nil { + t.Fatalf("expected nil error after successful emit, got %v", execErr) + } + if result.Content != "ok" { + t.Fatalf("expected successful tool result, got %+v", result) + } +} + +func TestExecuteToolCallWithPermissionReturnsContextCanceledWhenChunkNotDelivered(t *testing.T) { + t.Parallel() + + registry := tools.NewRegistry() + registry.Register(&stubTool{ + name: "filesystem_read_file", + executeFn: func(_ context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { + if input.EmitChunk == nil { + t.Fatalf("expected EmitChunk callback") + } + if err := input.EmitChunk([]byte("stream-chunk")); !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled from emitter, got %v", err) + } + return tools.NewErrorResult(input.Name, "emit failed", "", nil), context.Canceled + }, + }) + + engine, err := security.NewStaticGateway(security.DecisionAllow, nil) + if err != nil { + t.Fatalf("new static gateway: %v", err) + } + toolManager, err := tools.NewManager(registry, engine, nil) + if err != nil { + t.Fatalf("new tool manager: %v", err) + } + + service := NewWithFactory( + newRuntimeConfigManager(t), + toolManager, + newMemoryStore(), + &scriptedProviderFactory{provider: &scriptedProvider{}}, + nil, + ) + service.events = make(chan RuntimeEvent, 1) + service.events <- RuntimeEvent{Type: EventAgentChunk} + + baseCtx, cancel := context.WithCancel(context.Background()) + ctx := &doneSignalContext{ + Context: baseCtx, + doneCalled: make(chan struct{}), + } + go func() { + <-ctx.doneCalled + cancel() + }() + + _, execErr := service.executeToolCallWithPermission(ctx, permissionExecutionInput{ + RunID: "run-canceled-blocked", + SessionID: "session-canceled-blocked", + Call: providertypes.ToolCall{ + ID: "call-canceled-blocked", + Name: "filesystem_read_file", + Arguments: `{"path":"README.md"}`, + }, + ToolTimeout: time.Second, + }) + if !errors.Is(execErr, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", execErr) + } +} diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 2e13466d..02c9442d 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -211,6 +211,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { s.emit(ctx, EventUserMessage, input.RunID, session.ID, userMessage) autoCompacted := false + reactiveRetried := false for attempt := 0; ; attempt++ { if err := ctx.Err(); err != nil { @@ -249,14 +250,14 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } if builtContext.ShouldAutoCompact && !autoCompacted { - autoCompacted = true var compactResult contextcompact.Result - session, compactResult, _ = s.runCompactForSession(ctx, input.RunID, session, cfg, false) + session, compactResult, _ = s.runCompactForSession(ctx, input.RunID, session, cfg, contextcompact.ModeAuto, false) if compactResult.Applied { - s.sessionInputTokens = 0 - s.sessionOutputTokens = 0 - session.TokenInputTotal = 0 - session.TokenOutputTotal = 0 + autoCompacted = true + s.resetSessionTokenTotals(&session) + // 自动 compact 成功后需要在同一轮重建上下文,避免继续沿用压缩前的请求内容。 + attempt-- + continue } } @@ -274,6 +275,25 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { Tools: toolSpecs, }) if err != nil { + if provider.IsContextTooLong(err) && !reactiveRetried { + reactiveRetried = true + var compactResult contextcompact.Result + session, compactResult, _ = s.runCompactForSession( + ctx, + input.RunID, + session, + cfg, + contextcompact.ModeReactive, + false, + ) + if compactResult.Applied { + s.resetSessionTokenTotals(&session) + autoCompacted = true + } + // Don't count the reactive-compact retry as a new reasoning turn. + attempt-- + continue + } return s.handleRunError(ctx, input.RunID, session.ID, err) } if err := ctx.Err(); err != nil { @@ -471,10 +491,10 @@ func (s *Service) loadOrCreateSession( return session, nil } -// emit 向事件通道发送事件。 +// emit 向事件通道发送事件,并在通道阻塞且上下文取消时返回对应错误。 // 先尝试非阻塞发送,确保即使 context 已取消,只要通道有空间事件就能被投递; -// 仅在通道已满时才通过 ctx.Done() 退出,避免 goroutine 泄漏。 -func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessionID string, payload any) { +// 仅在通道已满时才通过 ctx.Done() 退出,避免 goroutine 泄漏并向调用方反馈未投递状态。 +func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessionID string, payload any) error { evt := RuntimeEvent{ Type: kind, RunID: runID, @@ -483,12 +503,14 @@ func (s *Service) emit(ctx context.Context, kind EventType, runID string, sessio } select { case s.events <- evt: - return + return nil default: } select { case s.events <- evt: + return nil case <-ctx.Done(): + return ctx.Err() } } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index ac40bf8a..6f1cb87c 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -221,7 +221,9 @@ func (t *stubTool) Execute(ctx context.Context, input tools.ToolCallInput) (tool return t.executeFn(ctx, input) } if input.EmitChunk != nil { - input.EmitChunk([]byte("chunk")) + if err := input.EmitChunk([]byte("chunk")); err != nil { + return tools.NewErrorResult(t.name, "emit failed", "", nil), err + } } return tools.ToolResult{ Name: t.name, @@ -1728,7 +1730,9 @@ func TestServiceRunCanceledDuringToolExecution(t *testing.T) { name: "filesystem_edit", executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { if input.EmitChunk != nil { - input.EmitChunk([]byte("chunk")) + if err := input.EmitChunk([]byte("chunk")); err != nil { + return tools.NewErrorResult(input.Name, "emit failed", "", nil), err + } } close(toolStarted) <-ctx.Done() @@ -1788,7 +1792,9 @@ func TestServiceRunPreservesToolErrorAfterCancel(t *testing.T) { name: "filesystem_edit", executeFn: func(ctx context.Context, input tools.ToolCallInput) (tools.ToolResult, error) { if input.EmitChunk != nil { - input.EmitChunk([]byte("chunk")) + if err := input.EmitChunk([]byte("chunk")); err != nil { + return tools.NewErrorResult(input.Name, "emit failed", "", nil), err + } } close(toolStarted) <-ctx.Done() @@ -3296,7 +3302,7 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { BeforeChars: 60, AfterChars: 24, SavedRatio: 0.6, - TriggerMode: string(contextcompact.ModeManual), + TriggerMode: string(contextcompact.ModeAuto), }, TranscriptID: "transcript_auto", TranscriptPath: "/tmp/auto.jsonl", @@ -3315,8 +3321,11 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { if len(compactRunner.calls) != 1 { t.Fatalf("expected auto compact to run once, got %d", len(compactRunner.calls)) } - if len(builder.builds) != 2 { - t.Fatalf("expected 2 build attempts, got %d", len(builder.builds)) + if compactRunner.calls[0].Mode != contextcompact.ModeAuto { + t.Fatalf("expected compact mode %q, got %q", contextcompact.ModeAuto, compactRunner.calls[0].Mode) + } + if len(builder.builds) != 3 { + t.Fatalf("expected 3 build attempts, got %d", len(builder.builds)) } if builder.builds[0].Metadata.SessionInputTokens != 100 { t.Fatalf("expected first build to see pre-compact tokens, got %d", builder.builds[0].Metadata.SessionInputTokens) @@ -3333,6 +3342,18 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { if builder.builds[1].Metadata.SessionOutputTokens != 0 { t.Fatalf("expected second build to see reset output tokens, got %d", builder.builds[1].Metadata.SessionOutputTokens) } + if len(scripted.requests) != 2 { + t.Fatalf("expected 2 provider requests after tool follow-up, got %d", len(scripted.requests)) + } + if len(scripted.requests[0].Messages) != 2 { + t.Fatalf("expected rebuilt compacted context to be sent, got %+v", scripted.requests[0].Messages) + } + if scripted.requests[0].Messages[0].Content != "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue" { + t.Fatalf("expected first provider request to use compact summary, got %+v", scripted.requests[0].Messages) + } + if scripted.requests[0].Messages[1].Content != "latest answer" { + t.Fatalf("expected first provider request to use compacted latest answer, got %+v", scripted.requests[0].Messages) + } if service.sessionInputTokens != 0 { t.Fatalf("expected service input tokens to reset, got %d", service.sessionInputTokens) @@ -3365,6 +3386,438 @@ func TestServiceRunAutoCompactsAndResetsSessionTokens(t *testing.T) { EventAgentDone, }) assertNoEventType(t, events, EventCompactError) + + foundAutoDone := false + for _, event := range events { + if event.Type != EventCompactDone { + continue + } + payload, ok := event.Payload.(CompactDonePayload) + if !ok { + t.Fatalf("expected CompactDonePayload, got %T", event.Payload) + } + if payload.TriggerMode != string(contextcompact.ModeAuto) { + t.Fatalf("expected trigger mode %q, got %q", contextcompact.ModeAuto, payload.TriggerMode) + } + foundAutoDone = true + } + if !foundAutoDone { + t.Fatalf("expected auto compact_done event in %+v", events) + } +} + +func TestServiceRunAutoCompactNoopDoesNotDisableReactiveRetry(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.Context.AutoCompact.Enabled = true + cfg.Context.AutoCompact.InputTokenThreshold = 100 + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + + store := newMemoryStore() + session := agentsession.New("auto-noop-reactive") + session.ID = "session-auto-noop-reactive" + session.TokenInputTotal = 100 + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older request"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + builder := &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{ + SystemPrompt: "auto compact prompt", + Messages: append([]providertypes.Message(nil), input.Messages...), + ShouldAutoCompact: input.Metadata.SessionInputTokens >= input.Compact.AutoCompactThreshold, + }, nil + }, + } + + callCount := 0 + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + callCount++ + if callCount == 1 { + return &provider.ProviderError{ + StatusCode: 400, + Code: provider.ErrorCodeContextTooLong, + Message: "maximum context length exceeded", + } + } + select { + case events <- providertypes.NewTextDeltaStreamEvent("recovered after reactive compact"): + case <-ctx.Done(): + return ctx.Err() + } + select { + case events <- providertypes.NewMessageDoneStreamEvent("stop", nil): + case <-ctx.Done(): + return ctx.Err() + } + return nil + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + compactRunner := &stubCompactRunner{ + runFn: func(ctx context.Context, input contextcompact.Input) (contextcompact.Result, error) { + switch input.Mode { + case contextcompact.ModeAuto: + return contextcompact.Result{ + Messages: append([]providertypes.Message(nil), input.Messages...), + Applied: false, + Metrics: contextcompact.Metrics{ + BeforeChars: 40, + AfterChars: 40, + TriggerMode: string(contextcompact.ModeAuto), + }, + }, nil + case contextcompact.ModeReactive: + return contextcompact.Result{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, + {Role: providertypes.RoleUser, Content: "continue"}, + }, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 80, + AfterChars: 30, + SavedRatio: 0.625, + TriggerMode: string(contextcompact.ModeReactive), + }, + }, nil + default: + t.Fatalf("unexpected compact mode %q", input.Mode) + return contextcompact.Result{}, nil + } + }, + } + service.compactRunner = compactRunner + + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-auto-noop-reactive", + Content: "continue", + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + if len(compactRunner.calls) != 2 { + t.Fatalf("expected auto noop then reactive compact, got %d calls", len(compactRunner.calls)) + } + if compactRunner.calls[0].Mode != contextcompact.ModeAuto { + t.Fatalf("expected first compact mode %q, got %q", contextcompact.ModeAuto, compactRunner.calls[0].Mode) + } + if compactRunner.calls[1].Mode != contextcompact.ModeReactive { + t.Fatalf("expected second compact mode %q, got %q", contextcompact.ModeReactive, compactRunner.calls[1].Mode) + } + if scripted.callCount != 2 { + t.Fatalf("expected provider to be called twice, got %d", scripted.callCount) + } +} + +func TestServiceRunReactivelyCompactsOnContextTooLong(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + session := agentsession.New("reactive-compact") + session.ID = "session-reactive-compact" + session.TokenInputTotal = 220 + session.TokenOutputTotal = 70 + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older request"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + builder := &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{ + SystemPrompt: "reactive compact prompt", + Messages: append([]providertypes.Message(nil), input.Messages...), + }, nil + }, + } + + callCount := 0 + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + callCount++ + if callCount == 1 { + return &provider.ProviderError{ + StatusCode: 400, + Code: provider.ErrorCodeContextTooLong, + Message: "maximum context length exceeded", + Retryable: false, + } + } + select { + case events <- providertypes.NewTextDeltaStreamEvent("recovered"): + case <-ctx.Done(): + return ctx.Err() + } + select { + case events <- providertypes.NewMessageDoneStreamEvent("stop", nil): + case <-ctx.Done(): + return ctx.Err() + } + return nil + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, builder) + service.compactRunner = &stubCompactRunner{ + result: contextcompact.Result{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, + {Role: providertypes.RoleUser, Content: "continue"}, + }, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 120, + AfterChars: 48, + SavedRatio: 0.6, + TriggerMode: string(contextcompact.ModeReactive), + }, + TranscriptID: "transcript_reactive", + TranscriptPath: "/tmp/reactive.jsonl", + }, + } + + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-reactive-compact", + Content: "continue", + }); err != nil { + t.Fatalf("Run() error = %v", err) + } + + compactRunner := service.compactRunner.(*stubCompactRunner) + if len(compactRunner.calls) != 1 { + t.Fatalf("expected reactive compact to run once, got %d", len(compactRunner.calls)) + } + if compactRunner.calls[0].Mode != contextcompact.ModeReactive { + t.Fatalf("expected compact mode %q, got %q", contextcompact.ModeReactive, compactRunner.calls[0].Mode) + } + if len(builder.builds) != 2 { + t.Fatalf("expected 2 build attempts, got %d", len(builder.builds)) + } + if builder.builds[0].Metadata.SessionInputTokens != 220 { + t.Fatalf("expected first build to see pre-compact input tokens, got %d", builder.builds[0].Metadata.SessionInputTokens) + } + if builder.builds[1].Metadata.SessionInputTokens != 0 { + t.Fatalf("expected second build to see reset input tokens, got %d", builder.builds[1].Metadata.SessionInputTokens) + } + if scripted.callCount != 2 { + t.Fatalf("expected provider to be called twice, got %d", scripted.callCount) + } + + saved, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load compacted session: %v", err) + } + if saved.TokenInputTotal != 0 || saved.TokenOutputTotal != 0 { + t.Fatalf("expected persisted token totals to reset, got input=%d output=%d", saved.TokenInputTotal, saved.TokenOutputTotal) + } + if len(saved.Messages) != 3 { + t.Fatalf("expected compacted transcript plus final assistant reply, got %+v", saved.Messages) + } + if saved.Messages[2].Content != "recovered" { + t.Fatalf("expected final assistant reply %q, got %q", "recovered", saved.Messages[2].Content) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventUserMessage, + EventCompactStart, + EventCompactDone, + EventAgentDone, + }) + assertNoEventType(t, events, EventCompactError) + + foundReactiveDone := false + for _, event := range events { + if event.Type != EventCompactDone { + continue + } + payload, ok := event.Payload.(CompactDonePayload) + if !ok { + t.Fatalf("expected CompactDonePayload, got %T", event.Payload) + } + if payload.TriggerMode != string(contextcompact.ModeReactive) { + t.Fatalf("expected trigger mode %q, got %q", contextcompact.ModeReactive, payload.TriggerMode) + } + foundReactiveDone = true + } + if !foundReactiveDone { + t.Fatalf("expected reactive compact_done event in %+v", events) + } +} + +func TestServiceRunReactivelyCompactsWithinSingleLoopBudget(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + if err := manager.Update(context.Background(), func(cfg *config.Config) error { + cfg.MaxLoops = 1 + return nil + }); err != nil { + t.Fatalf("update config: %v", err) + } + + store := newMemoryStore() + session := agentsession.New("reactive-single-loop") + session.ID = "session-reactive-single-loop" + session.TokenInputTotal = 160 + session.Messages = []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "older request"}, + {Role: providertypes.RoleAssistant, Content: "older answer"}, + } + store.sessions[session.ID] = cloneSession(session) + + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + if len(req.Messages) == 3 { + return &provider.ProviderError{ + StatusCode: 400, + Code: provider.ErrorCodeContextTooLong, + Message: "maximum context length exceeded", + } + } + select { + case events <- providertypes.NewTextDeltaStreamEvent("recovered within one loop"): + case <-ctx.Done(): + return ctx.Err() + } + select { + case events <- providertypes.NewMessageDoneStreamEvent("stop", nil): + case <-ctx.Done(): + return ctx.Err() + } + return nil + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) + service.compactRunner = &stubCompactRunner{ + result: contextcompact.Result{ + Messages: []providertypes.Message{ + {Role: providertypes.RoleAssistant, Content: "[compact_summary]\ndone:\n- archived\n\nin_progress:\n- continue"}, + {Role: providertypes.RoleUser, Content: "continue"}, + }, + Applied: true, + Metrics: contextcompact.Metrics{ + BeforeChars: 80, + AfterChars: 30, + SavedRatio: 0.625, + TriggerMode: string(contextcompact.ModeReactive), + }, + }, + } + + if err := service.Run(context.Background(), UserInput{ + SessionID: session.ID, + RunID: "run-reactive-single-loop", + Content: "continue", + }); err != nil { + t.Fatalf("Run() with MaxLoops=1 should recover, got %v", err) + } + + if scripted.callCount != 2 { + t.Fatalf("expected provider to be called twice within one loop budget, got %d", scripted.callCount) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventUserMessage, + EventCompactStart, + EventCompactDone, + EventAgentDone, + }) + assertNoEventType(t, events, EventError) +} + +func TestServiceRunReactiveCompactRetriesOnlyOnce(t *testing.T) { + t.Parallel() + + manager := newRuntimeConfigManager(t) + store := newMemoryStore() + registry := tools.NewRegistry() + registry.Register(&stubTool{name: "filesystem_read_file", content: "default"}) + + scripted := &scriptedProvider{ + chatFn: func(ctx context.Context, req providertypes.ChatRequest, events chan<- providertypes.StreamEvent) error { + return &provider.ProviderError{ + StatusCode: 400, + Code: provider.ErrorCodeContextTooLong, + Message: "prompt is too long", + Retryable: false, + } + }, + } + + service := NewWithFactory(manager, registry, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) + service.compactRunner = &stubCompactRunner{ + err: errors.New("compact failed"), + } + + err := service.Run(context.Background(), UserInput{ + RunID: "run-reactive-compact-once", + Content: "continue", + }) + if err == nil || !containsError(err, "prompt is too long") { + t.Fatalf("expected final context-too-long error, got %v", err) + } + + compactRunner := service.compactRunner.(*stubCompactRunner) + if len(compactRunner.calls) != 1 { + t.Fatalf("expected reactive compact to run once, got %d", len(compactRunner.calls)) + } + if scripted.callCount != 2 { + t.Fatalf("expected provider to be called exactly twice, got %d", scripted.callCount) + } + + events := collectRuntimeEvents(service.Events()) + assertEventSequence(t, events, []EventType{ + EventUserMessage, + EventCompactStart, + EventCompactError, + EventError, + }) + assertNoEventType(t, events, EventCompactDone) + + foundReactiveError := false + for _, event := range events { + if event.Type != EventCompactError { + continue + } + payload, ok := event.Payload.(CompactErrorPayload) + if !ok { + t.Fatalf("expected CompactErrorPayload, got %T", event.Payload) + } + if payload.TriggerMode != string(contextcompact.ModeReactive) { + t.Fatalf("expected trigger mode %q, got %q", contextcompact.ModeReactive, payload.TriggerMode) + } + foundReactiveError = true + } + if !foundReactiveError { + t.Fatalf("expected reactive compact_error event in %+v", events) + } } func TestRestoreSessionTokens(t *testing.T) { diff --git a/internal/tools/filesystem/read_file.go b/internal/tools/filesystem/read_file.go index 7919fdeb..f99de8ef 100644 --- a/internal/tools/filesystem/read_file.go +++ b/internal/tools/filesystem/read_file.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "os" "path/filepath" "strings" @@ -14,6 +15,8 @@ import ( const emitChunkSize = 4 * 1024 +var errReadFileEmitChunkFailed = errors.New(readFileToolName + ": emit chunk failed") + type ReadFileTool struct { root string } @@ -100,12 +103,25 @@ func (t *ReadFileTool) Execute(ctx context.Context, input tools.ToolCallInput) ( if input.EmitChunk != nil { content := []byte(result.Content) + emittedBytes := 0 for start := 0; start < len(content); start += emitChunkSize { end := start + emitChunkSize if end > len(content) { end = len(content) } - input.EmitChunk(content[start:end]) + if emitErr := input.EmitChunk(content[start:end]); emitErr != nil { + err := fmt.Errorf("%w: %w", errReadFileEmitChunkFailed, emitErr) + return tools.NewErrorResult( + t.Name(), + tools.NormalizeErrorReason(t.Name(), err), + "", + map[string]any{ + "path": target, + "emitted_bytes": emittedBytes, + }, + ), err + } + emittedBytes += end - start } } diff --git a/internal/tools/filesystem/read_file_test.go b/internal/tools/filesystem/read_file_test.go index 739a5ebd..6f228ede 100644 --- a/internal/tools/filesystem/read_file_test.go +++ b/internal/tools/filesystem/read_file_test.go @@ -3,6 +3,7 @@ package filesystem import ( "context" "encoding/json" + "errors" "os" "path/filepath" "strings" @@ -80,10 +81,11 @@ func TestReadFileToolExecute(t *testing.T) { Name: tool.Name(), Arguments: args, Workdir: workspace, - EmitChunk: func(chunk []byte) { + EmitChunk: func(chunk []byte) error { if len(chunk) > 0 { chunks++ } + return nil }, }) @@ -151,10 +153,11 @@ func TestReadFileToolErrorFormattingAndTruncation(t *testing.T) { Name: tool.Name(), Arguments: tt.arguments, Workdir: workspace, - EmitChunk: func(chunk []byte) { + EmitChunk: func(chunk []byte) error { if len(chunk) > 0 { chunks++ } + return nil }, }) @@ -179,3 +182,107 @@ func TestReadFileToolErrorFormattingAndTruncation(t *testing.T) { }) } } + +func TestReadFileToolExecuteStopsOnEmitChunkError(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + content := strings.Repeat("chunk-data-", 500) + if err := os.WriteFile(filepath.Join(workspace, "large.txt"), []byte(content), 0o644); err != nil { + t.Fatalf("write large file: %v", err) + } + + tool := New(workspace) + args := mustMarshalFSArgs(t, map[string]string{"path": "large.txt"}) + + emitCount := 0 + consumerErr := errors.New("consumer closed") + result, err := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + EmitChunk: func(chunk []byte) error { + emitCount++ + if emitCount == 1 { + return consumerErr + } + return nil + }, + }) + if err == nil || !errors.Is(err, errReadFileEmitChunkFailed) { + t.Fatalf("expected emit chunk failure, got %v", err) + } + if !errors.Is(err, consumerErr) { + t.Fatalf("expected wrapped consumer error, got %v", err) + } + if !result.IsError { + t.Fatalf("expected error result, got %+v", result) + } + if result.Metadata["emitted_bytes"] != 0 { + t.Fatalf("expected emitted_bytes=0 before first successful emit, got %+v", result.Metadata) + } +} + +func TestReadFileToolExecuteEmitsProgressBeforeChunkFailure(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + content := strings.Repeat("chunk-data-", 500) + if err := os.WriteFile(filepath.Join(workspace, "large.txt"), []byte(content), 0o644); err != nil { + t.Fatalf("write large file: %v", err) + } + + tool := New(workspace) + args := mustMarshalFSArgs(t, map[string]string{"path": "large.txt"}) + + emitCount := 0 + consumerErr := errors.New("consumer closed on second chunk") + result, err := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + EmitChunk: func(chunk []byte) error { + emitCount++ + if emitCount == 2 { + return consumerErr + } + return nil + }, + }) + if err == nil || !errors.Is(err, errReadFileEmitChunkFailed) { + t.Fatalf("expected emit chunk failure, got %v", err) + } + if !errors.Is(err, consumerErr) { + t.Fatalf("expected wrapped consumer error, got %v", err) + } + if !result.IsError { + t.Fatalf("expected error result, got %+v", result) + } + if result.Metadata["emitted_bytes"] != emitChunkSize { + t.Fatalf("expected emitted_bytes=%d after first successful chunk, got %+v", emitChunkSize, result.Metadata) + } +} + +func TestReadFileToolExecuteWithoutChunkEmitter(t *testing.T) { + t.Parallel() + + workspace := t.TempDir() + if err := os.WriteFile(filepath.Join(workspace, "small.txt"), []byte("hello without stream"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + tool := New(workspace) + args := mustMarshalFSArgs(t, map[string]string{"path": "small.txt"}) + + result, err := tool.Execute(context.Background(), tools.ToolCallInput{ + Name: tool.Name(), + Arguments: args, + Workdir: workspace, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Content != "hello without stream" { + t.Fatalf("unexpected content: %q", result.Content) + } +} diff --git a/internal/tools/types.go b/internal/tools/types.go index 77bd75bb..462ec8d9 100644 --- a/internal/tools/types.go +++ b/internal/tools/types.go @@ -15,7 +15,15 @@ type Tool interface { Execute(ctx context.Context, call ToolCallInput) (ToolResult, error) } -type ChunkEmitter func(chunk []byte) +// ChunkEmitter 是工具执行过程中向上游发送流式分片的回调。 +// 并发语义: +// - 单次 Execute 内允许调用 0 次或多次; +// - 同一次 Execute 内默认要求串行调用,工具实现不应并发调用同一个 emitter; +// - 若工具确需跨 goroutine 使用,必须自行保证顺序、同步与上游消费方的并发安全契约; +// - 调用方若返回非 nil error,工具应停止后续分片发送并尽快中止执行。 +// 内存语义: +// - 回调返回后不得继续持有传入的 chunk 引用,若需异步使用必须先复制。 +type ChunkEmitter func(chunk []byte) error type ToolCallInput struct { ID string @@ -24,7 +32,8 @@ type ToolCallInput struct { SessionID string Workdir string WorkspacePlan *security.WorkspaceExecutionPlan - EmitChunk ChunkEmitter + // EmitChunk 为流式分片回调,语义见 ChunkEmitter 注释。 + EmitChunk ChunkEmitter } type ToolResult struct { diff --git a/internal/tui/bootstrap/builder_test.go b/internal/tui/bootstrap/builder_test.go index 71a9cf06..4ed2e903 100644 --- a/internal/tui/bootstrap/builder_test.go +++ b/internal/tui/bootstrap/builder_test.go @@ -2,6 +2,7 @@ package bootstrap import ( "context" + "errors" "testing" "neo-code/internal/config" @@ -164,3 +165,134 @@ func TestNormalizeMode(t *testing.T) { }) } } + +type errorFactory struct { + runtimeErr error + providerErr error + runtimeNil bool + providerNil bool +} + +func (f errorFactory) BuildRuntime(mode Mode, current agentruntime.Runtime) (agentruntime.Runtime, error) { + if f.runtimeErr != nil { + return nil, f.runtimeErr + } + if f.runtimeNil { + return nil, nil + } + return current, nil +} + +func (f errorFactory) BuildProvider(mode Mode, current ProviderService) (ProviderService, error) { + if f.providerErr != nil { + return nil, f.providerErr + } + if f.providerNil { + return nil, nil + } + return current, nil +} + +type noopRuntime struct{} + +func (r noopRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (r noopRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (r noopRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + return nil +} + +func (r noopRuntime) Events() <-chan agentruntime.RuntimeEvent { + ch := make(chan agentruntime.RuntimeEvent) + close(ch) + return ch +} + +func (r noopRuntime) CancelActiveRun() bool { + return false +} + +func (r noopRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return nil, nil +} + +func (r noopRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func (r noopRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +type noopProviderService struct{} + +func (s noopProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return nil, nil +} + +func (s noopProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func (s noopProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s noopProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, nil +} + +func (s noopProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, nil +} + +func TestBuildFactoryErrors(t *testing.T) { + manager := &config.Manager{} + runtimeSvc := noopRuntime{} + providerSvc := noopProviderService{} + + _, err := Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Factory: errorFactory{runtimeErr: errors.New("runtime boom")}, + }) + if err == nil { + t.Fatalf("expected runtime factory error") + } + + _, err = Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Factory: errorFactory{providerErr: errors.New("provider boom")}, + }) + if err == nil { + t.Fatalf("expected provider factory error") + } + + _, err = Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Factory: errorFactory{runtimeNil: true}, + }) + if err == nil { + t.Fatalf("expected nil runtime factory error") + } + + _, err = Build(Options{ + ConfigManager: manager, + Runtime: runtimeSvc, + ProviderService: providerSvc, + Factory: errorFactory{providerNil: true}, + }) + if err == nil { + t.Fatalf("expected nil provider factory error") + } +} diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index f290ce30..957eec99 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -37,6 +37,7 @@ const ( pickerProvider pickerMode = tuistate.PickerProvider pickerModel pickerMode = tuistate.PickerModel pickerFile pickerMode = tuistate.PickerFile + pickerHelp pickerMode = tuistate.PickerHelp ) type RuntimeMsg = tuistate.RuntimeMsg @@ -47,6 +48,7 @@ type compactFinishedMsg = tuistate.CompactFinishedMsg type localCommandResultMsg = tuistate.LocalCommandResultMsg type sessionWorkdirResultMsg = tuistate.SessionWorkdirResultMsg type workspaceCommandResultMsg = tuistate.WorkspaceCommandResultMsg +type permissionResolutionFinishedMsg = tuistate.PermissionResolutionFinishedMsg type ProviderController interface { ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) @@ -73,6 +75,7 @@ type appComponents struct { commandMenuMeta tuistate.CommandMenuMeta providerPicker list.Model modelPicker list.Model + helpPicker list.Model fileBrowser filepicker.Model progress progress.Model transcript viewport.Model @@ -83,22 +86,24 @@ type appComponents struct { // appRuntimeState 聚合运行期易变字段,降低 App 顶层字段密度。 type appRuntimeState struct { - codeCopyBlocks map[int]string - pendingCopyID int - nowFn func() time.Time - lastInputEditAt time.Time - lastPasteLikeAt time.Time - inputBurstStart time.Time - inputBurstCount int - pasteMode bool - activeMessages []providertypes.Message - activities []tuistate.ActivityEntry - fileCandidates []string - modelRefreshID string - focus panel - runProgressValue float64 - runProgressKnown bool - runProgressLabel string + codeCopyBlocks map[int]string + pendingCopyID int + deferredEventCmd tea.Cmd + nowFn func() time.Time + lastInputEditAt time.Time + lastPasteLikeAt time.Time + inputBurstStart time.Time + inputBurstCount int + pasteMode bool + activeMessages []providertypes.Message + activities []tuistate.ActivityEntry + fileCandidates []string + modelRefreshID string + focus panel + runProgressValue float64 + runProgressKnown bool + runProgressLabel string + pendingPermission *permissionPromptState } type App struct { @@ -221,6 +226,7 @@ func newApp(container tuibootstrap.Container) (App, error) { commandMenu: commandMenu, providerPicker: newSelectionPickerItems(nil), modelPicker: newSelectionPickerItems(nil), + helpPicker: newHelpPickerItems(nil), fileBrowser: fileBrowser, progress: progressBar, transcript: viewport.New(0, 0), @@ -255,6 +261,7 @@ func newApp(container tuibootstrap.Container) (App, error) { if err := app.refreshModelPicker(); err != nil { return App{}, err } + app.refreshHelpPicker() app.selectCurrentProvider(cfg.SelectedProvider) app.selectCurrentModel(cfg.CurrentModel) app.modelRefreshID = cfg.SelectedProvider diff --git a/internal/tui/core/app/command_menu.go b/internal/tui/core/app/command_menu.go index 9e593b79..25db49d1 100644 --- a/internal/tui/core/app/command_menu.go +++ b/internal/tui/core/app/command_menu.go @@ -186,11 +186,36 @@ func (a *App) resizeCommandMenu() { } func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, tuistate.CommandMenuMeta) { + trimmed := strings.TrimSpace(input) + + // 1. 优先检查 Slash 命令 + if strings.HasPrefix(trimmed, slashPrefix) { + suggestions := a.matchingSlashCommands(trimmed) + if len(suggestions) > 0 { + start, end, _, _ := tokenRange(input, tokenSelectorFirst) + items := make([]commandMenuItem, 0, len(suggestions)) + for _, suggestion := range suggestions { + items = append(items, commandMenuItem{ + title: suggestion.Command.Usage, + description: suggestion.Command.Description, + filter: suggestion.Command.Usage + " " + suggestion.Command.Description, + highlight: suggestion.Match, + replacement: suggestion.Command.Usage, + useReplaceRange: true, + replaceStart: start, + replaceEnd: end, + }) + } + return items, tuistate.CommandMenuMeta{Title: commandMenuTitle} + } + } + + // 2. 检查文件建议 (如果 Slash 命令不匹配) if suggestions := a.fileMenuSuggestions(input); len(suggestions) > 0 { return suggestions, tuistate.CommandMenuMeta{Title: fileMenuTitle} } - trimmed := strings.TrimSpace(input) + // 3. 检查工作区命令 (如果 Slash 命令和文件建议都不匹配) if isWorkspaceCommandInput(trimmed) { replacement := trimmed item := commandMenuItem{ @@ -209,26 +234,8 @@ func (a App) buildCommandMenuItems(input string, width int) ([]commandMenuItem, return []commandMenuItem{item}, tuistate.CommandMenuMeta{Title: shellMenuTitle} } - suggestions := a.matchingSlashCommands(trimmed) - if len(suggestions) == 0 { - return nil, tuistate.CommandMenuMeta{} - } - - start, end, _, _ := tokenRange(input, tokenSelectorFirst) - items := make([]commandMenuItem, 0, len(suggestions)) - for _, suggestion := range suggestions { - items = append(items, commandMenuItem{ - title: suggestion.Command.Usage, - description: suggestion.Command.Description, - filter: suggestion.Command.Usage + " " + suggestion.Command.Description, - highlight: suggestion.Match, - replacement: suggestion.Command.Usage, - useReplaceRange: true, - replaceStart: start, - replaceEnd: end, - }) - } - return items, tuistate.CommandMenuMeta{Title: commandMenuTitle} + // 如果没有任何匹配的建议 + return nil, tuistate.CommandMenuMeta{} } func (a App) fileMenuSuggestions(input string) []commandMenuItem { @@ -309,7 +316,7 @@ func (a *App) applySelectedCommandSuggestion() bool { func (a *App) updateCommandMenuSelection(msg tea.KeyMsg) (tea.Cmd, bool) { if !a.commandMenuHasSuggestions() { - return nil, false + return nil, false // 让按键继续传递 } switch msg.Type { @@ -318,7 +325,7 @@ func (a *App) updateCommandMenuSelection(msg tea.KeyMsg) (tea.Cmd, bool) { a.commandMenu, cmd = a.commandMenu.Update(msg) return cmd, true default: - return nil, false + return nil, false // 非导航键,让它们继续传递 } } diff --git a/internal/tui/core/app/command_menu_test.go b/internal/tui/core/app/command_menu_test.go index fa1ea274..1aae7bac 100644 --- a/internal/tui/core/app/command_menu_test.go +++ b/internal/tui/core/app/command_menu_test.go @@ -1,8 +1,11 @@ package tui import ( + "path/filepath" "strings" "testing" + + tea "github.com/charmbracelet/bubbletea" ) func TestCommandMenuItem(t *testing.T) { @@ -80,3 +83,133 @@ func TestCommandMenuView(t *testing.T) { t.Error("View() returned empty string") } } + +func TestBuildCommandMenuItemsForWorkspaceCommand(t *testing.T) { + app, _ := newTestApp(t) + app.state.CurrentWorkdir = "/workspace/root" + + items, meta := app.buildCommandMenuItems("&", 80) + if meta.Title != shellMenuTitle { + t.Fatalf("expected shell menu title, got %q", meta.Title) + } + if len(items) != 1 { + t.Fatalf("expected one item, got %d", len(items)) + } + if !items[0].useReplaceRange || items[0].replacement != workspaceCommandPrefix+" " { + t.Fatalf("expected workspace replace range") + } +} + +func TestBuildCommandMenuItemsForSlashCommands(t *testing.T) { + app, _ := newTestApp(t) + + items, meta := app.buildCommandMenuItems("/he", 80) + if meta.Title != commandMenuTitle { + t.Fatalf("expected command menu title, got %q", meta.Title) + } + if len(items) == 0 { + t.Fatalf("expected slash command suggestions") + } + found := false + for _, item := range items { + if item.replacement == slashUsageHelp { + found = true + } + } + if !found { + t.Fatalf("expected help suggestion to appear") + } +} + +func TestFileMenuSuggestionsEmptyQueryIncludesBrowse(t *testing.T) { + app, _ := newTestApp(t) + app.fileCandidates = []string{"README.md", "docs/guide.md"} + + items := app.fileMenuSuggestions("@") + if len(items) == 0 || !items[0].openFileBrowser { + t.Fatalf("expected browse file entry") + } +} + +func TestFileMenuSuggestionsMatchesQuery(t *testing.T) { + app, _ := newTestApp(t) + app.fileCandidates = []string{"README.md", "docs/guide.md"} + + items := app.fileMenuSuggestions("@read") + if len(items) == 0 { + t.Fatalf("expected file suggestions") + } + if items[0].replacement == "" { + t.Fatalf("expected replacement to be set") + } +} + +func TestApplySelectedCommandSuggestionReplacesInput(t *testing.T) { + app, _ := newTestApp(t) + app.input.SetValue("/he") + app.state.InputText = "/he" + app.transcript.Width = 80 + app.refreshCommandMenu() + + if !app.commandMenuHasSuggestions() { + t.Fatalf("expected suggestions") + } + if !app.applySelectedCommandSuggestion() { + t.Fatalf("expected suggestion to apply") + } + if app.input.Value() == "/he" { + t.Fatalf("expected input to change") + } +} + +func TestApplySelectedCommandSuggestionOpenFileBrowser(t *testing.T) { + app, _ := newTestApp(t) + app.state.CurrentWorkdir = t.TempDir() + app.fileCandidates = []string{"README.md"} + app.input.SetValue("@") + app.transcript.Width = 80 + app.refreshCommandMenu() + + if !app.commandMenuHasSuggestions() { + t.Fatalf("expected suggestions") + } + applied := app.applySelectedCommandSuggestion() + if !applied { + t.Fatalf("expected browse action to apply") + } + if app.state.ActivePicker != pickerFile { + t.Fatalf("expected file picker to open") + } +} + +func TestUpdateCommandMenuSelectionHandlesNavigationKeys(t *testing.T) { + app, _ := newTestApp(t) + app.input.SetValue("/he") + app.transcript.Width = 80 + app.refreshCommandMenu() + + _, handled := app.updateCommandMenuSelection(tea.KeyMsg{Type: tea.KeyDown}) + if !handled { + t.Fatalf("expected navigation key to be handled") + } + _, handled = app.updateCommandMenuSelection(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + if handled { + t.Fatalf("expected non-navigation key to be ignored") + } +} + +func TestOpenFileBrowserUsesAbsoluteWorkdir(t *testing.T) { + app, _ := newTestApp(t) + root := t.TempDir() + app.state.CurrentWorkdir = root + + app.openFileBrowser() + + expected, _ := filepath.Abs(root) + if app.fileBrowser.CurrentDirectory != expected { + t.Fatalf("expected absolute directory, got %q", app.fileBrowser.CurrentDirectory) + } + if app.state.ActivePicker != pickerFile { + t.Fatalf("expected file picker to be active") + } +} diff --git a/internal/tui/core/app/commands.go b/internal/tui/core/app/commands.go index ad373587..50d8d0bc 100644 --- a/internal/tui/core/app/commands.go +++ b/internal/tui/core/app/commands.go @@ -39,6 +39,8 @@ const ( providerPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" modelPickerTitle = "Select Model" modelPickerSubtitle = "Up/Down choose, Enter confirm, Esc cancel" + helpPickerTitle = "Slash Commands" + helpPickerSubtitle = "Up/Down choose, Enter run, Esc cancel" filePickerTitle = "Browse Files" filePickerSubtitle = "Navigate folders, Enter choose file, Esc cancel" @@ -52,24 +54,28 @@ const ( emptyConversationText = "No conversation yet.\nAsk NeoCode to inspect or change code, or type /help to browse local commands." emptyMessageText = "(empty)" - statusReady = "Ready" - statusRuntimeClosed = "Runtime closed" - statusThinking = "Thinking" - statusCanceling = "Canceling" - statusCanceled = "Canceled" - statusRunningTool = "Running tool" - statusToolFinished = "Tool finished" - statusToolError = "Tool error" - statusError = "Error" - statusDraft = "New draft" - statusRunning = "Running" - statusApplyingCommand = "Applying local command" - statusRunningCommand = "Running command" - statusCommandDone = "Command finished" - statusCompacting = "Compacting context" - statusChooseProvider = "Choose a provider" - statusChooseModel = "Choose a model" - statusBrowseFile = "Browse workspace files" + statusReady = "Ready" + statusRuntimeClosed = "Runtime closed" + statusThinking = "Thinking" + statusCanceling = "Canceling" + statusCanceled = "Canceled" + statusRunningTool = "Running tool" + statusToolFinished = "Tool finished" + statusToolError = "Tool error" + statusError = "Error" + statusDraft = "New draft" + statusRunning = "Running" + statusApplyingCommand = "Applying local command" + statusRunningCommand = "Running command" + statusCommandDone = "Command finished" + statusCompacting = "Compacting context" + statusChooseProvider = "Choose a provider" + statusChooseModel = "Choose a model" + statusChooseHelp = "Choose a slash command" + statusBrowseFile = "Browse workspace files" + statusPermissionRequired = "Permission required: choose a decision and press Enter" + statusPermissionSubmitting = "Submitting permission decision" + statusPermissionSubmitted = "Permission decision submitted" focusLabelSessions = "Sessions" focusLabelTranscript = "Transcript" @@ -119,6 +125,13 @@ func newSelectionPicker(items []list.Item) list.Model { return picker } +// newHelpPicker 创建 /help 专用选择器,禁用分页以保持单页展示体验。 +func newHelpPicker(items []list.Item) list.Model { + picker := newSelectionPicker(items) + picker.SetShowPagination(false) + return picker +} + func newCommandMenuModel(uiStyles styles) list.Model { delegate := commandMenuDelegate{styles: uiStyles} menu := list.New([]list.Item{}, delegate, 0, 0) @@ -141,6 +154,15 @@ func newSelectionPickerItems(items []selectionItem) list.Model { return newSelectionPicker(listItems) } +// newHelpPickerItems 将 slash 命令映射为 /help 弹层列表项。 +func newHelpPickerItems(items []selectionItem) list.Model { + listItems := make([]list.Item, 0, len(items)) + for _, item := range items { + listItems = append(listItems, item) + } + return newHelpPicker(listItems) +} + func mapProviderItems(items []config.ProviderCatalogItem) []selectionItem { mapped := make([]selectionItem, 0, len(items)) for _, item := range items { @@ -171,6 +193,13 @@ func replacePickerItems(current *list.Model, items []selectionItem) { *current = next } +// replaceHelpPickerItems 替换 /help 弹层条目并保持尺寸。 +func replaceHelpPickerItems(current *list.Model, items []selectionItem) { + next := newHelpPickerItems(items) + next.SetSize(current.Width(), current.Height()) + *current = next +} + func (a *App) refreshProviderPicker() error { items, err := a.providerSvc.ListProviders(context.Background()) if err != nil { @@ -193,6 +222,20 @@ func (a *App) refreshModelPicker() error { return nil } +// refreshHelpPicker 刷新 /help 弹层中的 slash 命令列表。 +func (a *App) refreshHelpPicker() { + items := make([]selectionItem, 0, len(builtinSlashCommands)) + for _, command := range builtinSlashCommands { + items = append(items, selectionItem{ + id: command.Usage, + name: command.Usage, + description: command.Description, + }) + } + replaceHelpPickerItems(&a.helpPicker, items) + selectPickerItemByID(&a.helpPicker, "") +} + func (a *App) openProviderPicker() { a.openPicker(pickerProvider, statusChooseProvider, &a.providerPicker, a.state.CurrentProvider) } @@ -201,6 +244,11 @@ func (a *App) openModelPicker() { a.openPicker(pickerModel, statusChooseModel, &a.modelPicker, a.state.CurrentModel) } +// openHelpPicker 打开 slash 命令帮助弹层并进入可选择状态。 +func (a *App) openHelpPicker() { + a.openPicker(pickerHelp, statusChooseHelp, &a.helpPicker, "") +} + func (a *App) openPicker(mode pickerMode, statusText string, picker *list.Model, selectedID string) { a.state.ActivePicker = mode a.state.StatusText = statusText diff --git a/internal/tui/core/app/commands_test.go b/internal/tui/core/app/commands_test.go index cef5e8b4..db1b0f6a 100644 --- a/internal/tui/core/app/commands_test.go +++ b/internal/tui/core/app/commands_test.go @@ -1,9 +1,15 @@ package tui import ( + "context" + "errors" + "strings" "testing" "github.com/charmbracelet/bubbles/list" + + "neo-code/internal/config" + tuistatus "neo-code/internal/tui/core/status" ) func TestBuiltinSlashCommands(t *testing.T) { @@ -68,6 +74,7 @@ func TestStatusConstants(t *testing.T) { {"statusCompacting", statusCompacting}, {"statusChooseProvider", statusChooseProvider}, {"statusChooseModel", statusChooseModel}, + {"statusChooseHelp", statusChooseHelp}, {"statusBrowseFile", statusBrowseFile}, } @@ -142,3 +149,170 @@ func TestMaxActivityEntries(t *testing.T) { t.Error("maxActivityEntries should not be zero") } } + +type errorProviderService struct { + err error +} + +func (s errorProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return nil, s.err +} + +func (s errorProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, s.err +} + +func (s errorProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, s.err +} + +func (s errorProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return nil, s.err +} + +func (s errorProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + return config.ProviderSelection{}, s.err +} + +func TestExecuteLocalCommandErrors(t *testing.T) { + app, _ := newTestApp(t) + snapshot := app.currentStatusSnapshot() + + if _, err := executeLocalCommand(context.Background(), app.configManager, app.providerSvc, snapshot, ""); err == nil { + t.Fatalf("expected empty command error") + } + if _, err := executeLocalCommand(context.Background(), app.configManager, app.providerSvc, snapshot, "/unknown"); err == nil { + t.Fatalf("expected unknown command error") + } +} + +func TestExecuteLocalCommandHelpAndStatus(t *testing.T) { + app, _ := newTestApp(t) + snapshot := app.currentStatusSnapshot() + + helpText, err := executeLocalCommand(context.Background(), app.configManager, app.providerSvc, snapshot, "/help") + if err != nil { + t.Fatalf("executeLocalCommand(/help) error = %v", err) + } + if !strings.Contains(helpText, "Available slash commands:") { + t.Fatalf("expected help output, got %q", helpText) + } + + statusText, err := executeLocalCommand(context.Background(), app.configManager, app.providerSvc, snapshot, "/status") + if err != nil { + t.Fatalf("executeLocalCommand(/status) error = %v", err) + } + if !strings.Contains(statusText, "Status:") { + t.Fatalf("expected status output, got %q", statusText) + } +} + +func TestExecuteProviderCommandValidation(t *testing.T) { + app, _ := newTestApp(t) + if _, err := executeProviderCommand(context.Background(), app.providerSvc, ""); err == nil { + t.Fatalf("expected usage error") + } +} + +func TestExecuteProviderCommandSuccess(t *testing.T) { + app, _ := newTestApp(t) + value := app.state.CurrentProvider + if strings.TrimSpace(value) == "" { + t.Fatalf("expected provider id to be set") + } + + message, err := executeProviderCommand(context.Background(), app.providerSvc, value) + if err != nil { + t.Fatalf("executeProviderCommand error = %v", err) + } + if !strings.Contains(message, value) { + t.Fatalf("expected provider id in message, got %q", message) + } +} + +func TestExecuteProviderCommandPropagatesError(t *testing.T) { + providerSvc := errorProviderService{err: errors.New("boom")} + if _, err := executeProviderCommand(context.Background(), providerSvc, "any"); err == nil { + t.Fatalf("expected provider error") + } +} + +func TestRunProviderSelectionCmd(t *testing.T) { + app, _ := newTestApp(t) + cmd := runProviderSelection(app.providerSvc, app.state.CurrentProvider) + if cmd == nil { + t.Fatalf("expected cmd") + } + msg := cmd() + result, ok := msg.(localCommandResultMsg) + if !ok { + t.Fatalf("expected localCommandResultMsg, got %T", msg) + } + if !result.ProviderChanged || !strings.Contains(result.Notice, app.state.CurrentProvider) { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestRunModelSelectionCmd(t *testing.T) { + app, _ := newTestApp(t) + cmd := runModelSelection(app.providerSvc, app.state.CurrentModel) + if cmd == nil { + t.Fatalf("expected cmd") + } + msg := cmd() + result, ok := msg.(localCommandResultMsg) + if !ok { + t.Fatalf("expected localCommandResultMsg, got %T", msg) + } + if !result.ModelChanged || !strings.Contains(result.Notice, app.state.CurrentModel) { + t.Fatalf("unexpected result: %#v", result) + } +} + +func TestRunModelCatalogRefreshCmd(t *testing.T) { + app, _ := newTestApp(t) + cmd := runModelCatalogRefresh(app.providerSvc, app.state.CurrentProvider) + if cmd == nil { + t.Fatalf("expected refresh cmd") + } + msg := cmd() + result, ok := msg.(modelCatalogRefreshMsg) + if !ok { + t.Fatalf("expected modelCatalogRefreshMsg, got %T", msg) + } + if !strings.EqualFold(result.ProviderID, app.state.CurrentProvider) { + t.Fatalf("unexpected provider id: %s", result.ProviderID) + } +} + +func TestExecuteStatusCommandFormatting(t *testing.T) { + snapshot := tuistatus.Snapshot{ + ActiveSessionTitle: "Draft", + CurrentProvider: "test-provider", + CurrentModel: "test-model", + CurrentWorkdir: "/tmp", + } + output := executeStatusCommand(snapshot) + if !strings.Contains(output, "Status:") { + t.Fatalf("expected Status header, got %q", output) + } +} + +func TestRefreshHelpPicker(t *testing.T) { + app, _ := newTestApp(t) + app.refreshHelpPicker() + if len(app.helpPicker.Items()) != len(builtinSlashCommands) { + t.Fatalf("expected %d help items, got %d", len(builtinSlashCommands), len(app.helpPicker.Items())) + } +} + +func TestOpenHelpPicker(t *testing.T) { + app, _ := newTestApp(t) + app.openHelpPicker() + if app.state.ActivePicker != pickerHelp { + t.Fatalf("expected help picker to open") + } + if app.state.StatusText != statusChooseHelp { + t.Fatalf("expected help picker status, got %q", app.state.StatusText) + } +} diff --git a/internal/tui/core/app/copy_code.go b/internal/tui/core/app/copy_code.go index 2bdb89ad..3f043d35 100644 --- a/internal/tui/core/app/copy_code.go +++ b/internal/tui/core/app/copy_code.go @@ -34,6 +34,15 @@ var ( copyCodeButtonPattern = regexp.MustCompile(`\[Copy code #([0-9]+)\]`) copyCodeANSIPattern = regexp.MustCompile(`\x1b\[[0-9;?]*[ -/]*[@-~]`) clipboardWriteAll = tuiinfra.CopyText + + codeFeaturePatterns = []*regexp.Regexp{ + regexp.MustCompile(`^[[:space:]]*(func|if|for|while|switch|case|return|class|def|const|let|var|import|export|package|struct|enum|interface|public|private|static|void|int|string|bool|nil|null|true|false)\b`), + regexp.MustCompile(`=>|->|::`), + regexp.MustCompile(`[})];?\s*$`), + regexp.MustCompile(`^\s*(//|#|/\*|\*)`), + regexp.MustCompile(`:=|=>`), + regexp.MustCompile(`\([a-zA-Z_][a-zA-Z0-9_]*(\s*,\s*[a-zA-Z_][a-zA-Z0-9_]*)*\)\s*{?$`), + } ) func splitMarkdownSegments(content string) []markdownSegment { @@ -123,6 +132,7 @@ func splitIndentedCodeSegments(content string) []markdownSegment { textLines := make([]string, 0, len(lines)) codeLines := make([]string, 0, len(lines)) inCode := false + codeFeatureCount := 0 flushText := func() { if len(textLines) == 0 { @@ -150,27 +160,38 @@ func splitIndentedCodeSegments(content string) []markdownSegment { Code: code, }) codeLines = codeLines[:0] + codeFeatureCount = 0 } for _, line := range lines { indented := isIndentedCodeLine(line) if inCode { - if indented { + if indented || hasCodeFeatures(line) { codeLines = append(codeLines, trimCodeIndent(line)) + if hasCodeFeatures(line) { + codeFeatureCount++ + } continue } if strings.TrimSpace(line) == "" { codeLines = append(codeLines, "") continue } - flushCode() + if len(codeLines) > 0 { + flushCode() + } inCode = false } - if indented { - flushText() - inCode = true + if indented || hasCodeFeatures(line) { + if !inCode { + flushText() + inCode = true + } codeLines = append(codeLines, trimCodeIndent(line)) + if hasCodeFeatures(line) { + codeFeatureCount++ + } continue } @@ -213,7 +234,23 @@ func isFenceCloseLine(line string) bool { } func isIndentedCodeLine(line string) bool { - return strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ") + if strings.HasPrefix(line, "\t") || strings.HasPrefix(line, " ") { + return true + } + return hasCodeFeatures(line) +} + +func hasCodeFeatures(line string) bool { + trimmed := strings.TrimLeft(line, " \t") + if trimmed == "" { + return false + } + for _, pattern := range codeFeaturePatterns { + if pattern.MatchString(line) { + return true + } + } + return false } func trimCodeIndent(line string) string { diff --git a/internal/tui/core/app/permission_prompt.go b/internal/tui/core/app/permission_prompt.go new file mode 100644 index 00000000..0bf31392 --- /dev/null +++ b/internal/tui/core/app/permission_prompt.go @@ -0,0 +1,174 @@ +package tui + +import ( + "fmt" + "strings" + "unicode" + + "github.com/charmbracelet/lipgloss" + + agentruntime "neo-code/internal/runtime" +) + +// permissionPromptOption 表示权限审批面板中的一个可选项。 +type permissionPromptOption struct { + Label string + Hint string + Decision agentruntime.PermissionResolutionDecision +} + +var permissionPromptOptions = []permissionPromptOption{ + { + Label: "Allow once", + Hint: "Approve this request once", + Decision: agentruntime.PermissionResolutionAllowOnce, + }, + { + Label: "Allow session", + Hint: "Approve similar requests for this session", + Decision: agentruntime.PermissionResolutionAllowSession, + }, + { + Label: "Reject", + Hint: "Reject this request", + Decision: agentruntime.PermissionResolutionReject, + }, +} + +// permissionPromptState 保存当前待审批请求与选项状态。 +type permissionPromptState struct { + Request agentruntime.PermissionRequestPayload + Selected int + Submitting bool +} + +// normalizePermissionPromptSelection 保证选项下标始终落在有效范围。 +func normalizePermissionPromptSelection(selected int) int { + if len(permissionPromptOptions) == 0 { + return 0 + } + if selected < 0 { + return len(permissionPromptOptions) - 1 + } + if selected >= len(permissionPromptOptions) { + return 0 + } + return selected +} + +// permissionPromptOptionAt 返回指定下标对应的审批选项。 +func permissionPromptOptionAt(selected int) permissionPromptOption { + index := normalizePermissionPromptSelection(selected) + return permissionPromptOptions[index] +} + +// parsePermissionShortcut 将快捷输入映射为审批决策。 +func parsePermissionShortcut(input string) (agentruntime.PermissionResolutionDecision, bool) { + switch strings.ToLower(strings.TrimSpace(input)) { + case "y", "yes", "once": + return agentruntime.PermissionResolutionAllowOnce, true + case "a", "always": + return agentruntime.PermissionResolutionAllowSession, true + case "n", "no", "reject", "deny": + return agentruntime.PermissionResolutionReject, true + default: + return "", false + } +} + +// formatPermissionPromptLines 构造权限审批面板展示文本。 +func formatPermissionPromptLines(state permissionPromptState) []string { + normalizedIdx := normalizePermissionPromptSelection(state.Selected) + lines := []string{ + fmt.Sprintf( + "Permission request: %s (%s)", + fallbackText(sanitizePermissionDisplayText(state.Request.ToolName), "unknown_tool"), + fallbackText(sanitizePermissionDisplayText(state.Request.Operation), "unknown"), + ), + fmt.Sprintf("Target: %s", fallbackText(sanitizePermissionDisplayText(state.Request.Target), "(empty)")), + "Use Up/Down to choose, Enter to confirm (shortcuts: y=once, a=session, n=reject)", + } + + for index, item := range permissionPromptOptions { + prefix := " " + if normalizedIdx == index { + prefix = "> " + } + lines = append(lines, fmt.Sprintf("%s%s - %s", prefix, item.Label, item.Hint)) + } + + if state.Submitting { + lines = append(lines, "Submitting permission decision...") + } + return lines +} + +// fallbackText 返回去空格后的值;为空时返回默认文案。 +func fallbackText(value string, fallback string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fallback + } + return trimmed +} + +// sanitizePermissionDisplayText 清理模型可控的终端展示文本,避免控制字符污染审批界面。 +func sanitizePermissionDisplayText(value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + + var builder strings.Builder + lastWasSpace := false + for _, r := range value { + if unicode.IsControl(r) || unicode.In(r, unicode.Cf) { + if !lastWasSpace { + builder.WriteByte(' ') + lastWasSpace = true + } + continue + } + builder.WriteRune(r) + lastWasSpace = unicode.IsSpace(r) + } + + return strings.TrimSpace(builder.String()) +} + +// parsePermissionRequestPayload 解析权限请求事件载荷。 +func parsePermissionRequestPayload(payload any) (agentruntime.PermissionRequestPayload, bool) { + switch typed := payload.(type) { + case agentruntime.PermissionRequestPayload: + return typed, true + case *agentruntime.PermissionRequestPayload: + if typed == nil { + return agentruntime.PermissionRequestPayload{}, false + } + return *typed, true + default: + return agentruntime.PermissionRequestPayload{}, false + } +} + +// parsePermissionResolvedPayload 解析权限决议事件载荷。 +func parsePermissionResolvedPayload(payload any) (agentruntime.PermissionResolvedPayload, bool) { + switch typed := payload.(type) { + case agentruntime.PermissionResolvedPayload: + return typed, true + case *agentruntime.PermissionResolvedPayload: + if typed == nil { + return agentruntime.PermissionResolvedPayload{}, false + } + return *typed, true + default: + return agentruntime.PermissionResolvedPayload{}, false + } +} + +// renderPermissionPrompt 渲染审批输入框内容,替代普通输入框文本编辑状态。 +func (a App) renderPermissionPrompt() string { + if a.pendingPermission == nil { + return a.input.View() + } + return lipgloss.JoinVertical(lipgloss.Left, formatPermissionPromptLines(*a.pendingPermission)...) +} diff --git a/internal/tui/core/app/permission_prompt_test.go b/internal/tui/core/app/permission_prompt_test.go new file mode 100644 index 00000000..db85fa81 --- /dev/null +++ b/internal/tui/core/app/permission_prompt_test.go @@ -0,0 +1,177 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/bubbles/textarea" + + agentruntime "neo-code/internal/runtime" +) + +func TestNormalizePermissionPromptSelectionWrap(t *testing.T) { + if got := normalizePermissionPromptSelection(-1); got != len(permissionPromptOptions)-1 { + t.Fatalf("expected -1 to wrap to last index, got %d", got) + } + if got := normalizePermissionPromptSelection(len(permissionPromptOptions)); got != 0 { + t.Fatalf("expected overflow index to wrap to 0, got %d", got) + } +} + +func TestNormalizePermissionPromptSelectionEmptyOptions(t *testing.T) { + original := permissionPromptOptions + permissionPromptOptions = nil + defer func() { permissionPromptOptions = original }() + + if got := normalizePermissionPromptSelection(99); got != 0 { + t.Fatalf("expected empty options to return 0, got %d", got) + } +} + +func TestPermissionPromptOptionAt(t *testing.T) { + option := permissionPromptOptionAt(-1) + if option.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("expected wrapped option to be reject, got %q", option.Decision) + } +} + +func TestParsePermissionShortcut(t *testing.T) { + tests := map[string]agentruntime.PermissionResolutionDecision{ + "y": agentruntime.PermissionResolutionAllowOnce, + "once": agentruntime.PermissionResolutionAllowOnce, + "a": agentruntime.PermissionResolutionAllowSession, + "always": agentruntime.PermissionResolutionAllowSession, + "n": agentruntime.PermissionResolutionReject, + "deny": agentruntime.PermissionResolutionReject, + } + for input, want := range tests { + got, ok := parsePermissionShortcut(input) + if !ok || got != want { + t.Fatalf("parsePermissionShortcut(%q) = (%q,%v), want (%q,true)", input, got, ok, want) + } + } + if _, ok := parsePermissionShortcut("unknown"); ok { + t.Fatalf("expected unknown shortcut to fail") + } +} + +func TestFormatPermissionPromptLines(t *testing.T) { + lines := formatPermissionPromptLines(permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{ + ToolName: "bash", + Operation: "exec", + Target: "git status", + }, + Selected: 1, + Submitting: true, + }) + joined := strings.Join(lines, "\n") + if !strings.Contains(joined, "Permission request") { + t.Fatalf("expected prompt header, got %q", joined) + } + if !strings.Contains(joined, "> Allow session") { + t.Fatalf("expected selected option marker, got %q", joined) + } + if !strings.Contains(joined, "Submitting permission decision") { + t.Fatalf("expected submitting hint, got %q", joined) + } +} + +func TestRenderPermissionPrompt(t *testing.T) { + app := App{ + appComponents: appComponents{input: textarea.New()}, + appRuntimeState: appRuntimeState{ + pendingPermission: &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{ + ToolName: "bash", + Target: "git status", + }, + Selected: 0, + }, + }, + } + rendered := app.renderPermissionPrompt() + if !strings.Contains(rendered, "Permission request") { + t.Fatalf("expected rendered permission prompt, got %q", rendered) + } + + app.pendingPermission = nil + app.input.SetValue("plain input") + rendered = app.renderPermissionPrompt() + if !strings.Contains(rendered, "plain input") { + t.Fatalf("expected fallback to input view, got %q", rendered) + } +} + +func TestParsePermissionPayloadHelpers(t *testing.T) { + req := agentruntime.PermissionRequestPayload{RequestID: "perm-1"} + if got, ok := parsePermissionRequestPayload(req); !ok || got.RequestID != "perm-1" { + t.Fatalf("unexpected parsePermissionRequestPayload result: %+v ok=%v", got, ok) + } + if _, ok := parsePermissionRequestPayload((*agentruntime.PermissionRequestPayload)(nil)); ok { + t.Fatalf("expected nil request pointer to fail parsing") + } + reqPtr := &agentruntime.PermissionRequestPayload{RequestID: "perm-1-ptr"} + if got, ok := parsePermissionRequestPayload(reqPtr); !ok || got.RequestID != "perm-1-ptr" { + t.Fatalf("unexpected pointer parsePermissionRequestPayload result: %+v ok=%v", got, ok) + } + if _, ok := parsePermissionRequestPayload("bad"); ok { + t.Fatalf("expected unsupported request payload type to fail parsing") + } + + resolved := agentruntime.PermissionResolvedPayload{RequestID: "perm-2"} + if got, ok := parsePermissionResolvedPayload(resolved); !ok || got.RequestID != "perm-2" { + t.Fatalf("unexpected parsePermissionResolvedPayload result: %+v ok=%v", got, ok) + } + if _, ok := parsePermissionResolvedPayload((*agentruntime.PermissionResolvedPayload)(nil)); ok { + t.Fatalf("expected nil resolved pointer to fail parsing") + } + resolvedPtr := &agentruntime.PermissionResolvedPayload{RequestID: "perm-2-ptr"} + if got, ok := parsePermissionResolvedPayload(resolvedPtr); !ok || got.RequestID != "perm-2-ptr" { + t.Fatalf("unexpected pointer parsePermissionResolvedPayload result: %+v ok=%v", got, ok) + } + if _, ok := parsePermissionResolvedPayload(123); ok { + t.Fatalf("expected unsupported resolved payload type to fail parsing") + } +} + +func TestSanitizePermissionDisplayText(t *testing.T) { + got := sanitizePermissionDisplayText("bash\x1b[31m\n./demo\t\u202egit status") + if strings.ContainsRune(got, '\x1b') { + t.Fatalf("expected escape characters to be removed, got %q", got) + } + if strings.Contains(got, "\u202e") { + t.Fatalf("expected format control characters to be removed, got %q", got) + } + if !strings.Contains(got, "bash [31m ./demo git status") { + t.Fatalf("expected printable content to remain, got %q", got) + } +} + +func TestRenderPromptWithPendingPermission(t *testing.T) { + input := textarea.New() + input.SetValue("normal message") + + app := App{ + appComponents: appComponents{ + input: input, + }, + styles: newStyles(), + appRuntimeState: appRuntimeState{ + pendingPermission: &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{ToolName: "bash", Target: "git status"}, + Selected: 0, + }, + }, + } + rendered := app.renderPrompt(80) + if !strings.Contains(rendered, "Permission request") { + t.Fatalf("expected permission prompt rendering branch, got %q", rendered) + } + + app.pendingPermission = nil + rendered = app.renderPrompt(80) + if !strings.Contains(rendered, "normal message") { + t.Fatalf("expected normal input rendering branch, got %q", rendered) + } +} diff --git a/internal/tui/core/app/styles.go b/internal/tui/core/app/styles.go index 15262a71..83c44a47 100644 --- a/internal/tui/core/app/styles.go +++ b/internal/tui/core/app/styles.go @@ -325,4 +325,3 @@ func preview(text string, width int, lines int) string { } return joined } - diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index e9face16..192ceabe 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -55,6 +55,10 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, tea.Batch(cmds...) case RuntimeMsg: transcriptDirty := a.handleRuntimeEvent(typed.Event) + if a.deferredEventCmd != nil { + cmds = append(cmds, a.deferredEventCmd) + a.deferredEventCmd = nil + } _ = a.refreshSessions() a.syncActiveSessionTitle() if transcriptDirty { @@ -67,6 +71,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ActiveRunID = "" + a.pendingPermission = nil a.clearRunProgress() a.state.IsCompacting = false if strings.TrimSpace(a.state.StatusText) == "" { @@ -77,6 +82,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if typed.Err != nil { a.state.IsAgentRunning = false a.state.ActiveRunID = "" + a.pendingPermission = nil a.clearRunProgress() a.state.StreamingReply = false a.state.CurrentTool = "" @@ -94,6 +100,22 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = a.refreshSessions() a.syncActiveSessionTitle() return a, tea.Batch(cmds...) + case permissionResolutionFinishedMsg: + if a.pendingPermission != nil && a.pendingPermission.Request.RequestID == typed.RequestID { + if typed.Err != nil { + a.pendingPermission.Submitting = false + a.state.ExecutionError = typed.Err.Error() + a.state.StatusText = typed.Err.Error() + a.appendActivity("permission", "Permission decision submit failed", typed.Err.Error(), true) + } else { + a.pendingPermission = nil + a.state.ExecutionError = "" + a.state.StatusText = statusPermissionSubmitted + a.appendActivity("permission", "Permission decision submitted", string(typed.Decision), false) + a.refreshPermissionPromptLayout() + } + } + return a, tea.Batch(cmds...) case modelCatalogRefreshMsg: if strings.EqualFold(a.modelRefreshID, typed.ProviderID) { a.modelRefreshID = "" @@ -301,6 +323,15 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te now := a.now() effectiveTyped := typed + if a.pendingPermission != nil { + if cmd, handled := a.updatePendingPermissionInput(typed); handled { + if cmd != nil { + cmds = append(cmds, cmd) + } + return a, tea.Batch(cmds...) + } + } + if key.Matches(typed, a.keys.Send) { if a.shouldTreatEnterAsNewline(typed, now) { a.growComposerForNewline() @@ -312,20 +343,31 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } - a.input.Reset() - a.state.InputText = "" - a.applyComponentLayout(true) - a.refreshCommandMenu() - a.resetPasteHeuristics() - + // 先检查是否是立即执行的命令,如果处理了,就直接返回 if handled, cmd := a.handleImmediateSlashCommand(input); handled { + a.input.Reset() // 只有在命令被处理后才清空输入 + a.state.InputText = "" + a.applyComponentLayout(true) + a.refreshCommandMenu() + a.resetPasteHeuristics() if cmd != nil { cmds = append(cmds, cmd) } return a, tea.Batch(cmds...) } + // 如果不是立即执行的命令,再执行常规的输入重置 + a.input.Reset() + a.state.InputText = "" + a.applyComponentLayout(true) + a.refreshCommandMenu() + a.resetPasteHeuristics() + switch strings.ToLower(input) { + case slashCommandHelp: + a.refreshHelpPicker() + a.openHelpPicker() + return a, tea.Batch(cmds...) case slashCommandProvider: if err := a.refreshProviderPicker(); err != nil { a.state.ExecutionError = err.Error() @@ -412,6 +454,55 @@ func (a App) updateInputPanel(msg tea.Msg, typed tea.KeyMsg, cmds []tea.Cmd) (te return a, tea.Batch(cmds...) } +// updatePendingPermissionInput 处理权限审批面板上的键盘交互(上下选择与回车确认)。 +func (a *App) updatePendingPermissionInput(typed tea.KeyMsg) (tea.Cmd, bool) { + if a.pendingPermission == nil { + return nil, false + } + if a.pendingPermission.Submitting { + return nil, true + } + + switch { + case key.Matches(typed, a.keys.ScrollUp): + a.pendingPermission.Selected = normalizePermissionPromptSelection(a.pendingPermission.Selected - 1) + a.state.StatusText = statusPermissionRequired + return nil, true + case key.Matches(typed, a.keys.ScrollDown): + a.pendingPermission.Selected = normalizePermissionPromptSelection(a.pendingPermission.Selected + 1) + a.state.StatusText = statusPermissionRequired + return nil, true + case key.Matches(typed, a.keys.Send): + option := permissionPromptOptionAt(a.pendingPermission.Selected) + return a.submitPermissionDecision(option.Decision), true + } + + if typed.Type == tea.KeyRunes && len(typed.Runes) > 0 { + if decision, ok := parsePermissionShortcut(string(typed.Runes)); ok { + return a.submitPermissionDecision(decision), true + } + } + return nil, true +} + +// submitPermissionDecision 触发一次权限审批提交命令。 +func (a *App) submitPermissionDecision(decision agentruntime.PermissionResolutionDecision) tea.Cmd { + if a.pendingPermission == nil { + return nil + } + + requestID := strings.TrimSpace(a.pendingPermission.Request.RequestID) + if requestID == "" { + return nil + } + + a.pendingPermission.Submitting = true + a.state.StatusText = statusPermissionSubmitting + a.appendActivity("permission", "Submitting permission decision", string(decision), false) + + return runResolvePermission(a.runtime, requestID, decision) +} + func (a App) now() time.Time { if a.nowFn == nil { return time.Now() @@ -526,6 +617,13 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, nil } return a, runModelSelection(a.providerSvc, item.id) + case pickerHelp: + item, ok := a.helpPicker.SelectedItem().(selectionItem) + a.closePicker() + if !ok { + return a, nil + } + return a, a.runSlashCommandSelection(item.id) } } @@ -535,6 +633,8 @@ func (a App) updatePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerPicker, cmd = a.providerPicker.Update(msg) case pickerModel: a.modelPicker, cmd = a.modelPicker.Update(msg) + case pickerHelp: + a.helpPicker, cmd = a.helpPicker.Update(msg) case pickerFile: a.fileBrowser, cmd = a.fileBrowser.Update(msg) if didSelect, path := a.fileBrowser.DidSelectFile(msg); didSelect { @@ -726,6 +826,8 @@ var runtimeEventHandlerRegistry = map[agentruntime.EventType]func(*App, agentrun agentruntime.EventRunCanceled: runtimeEventRunCanceledHandler, agentruntime.EventError: runtimeEventErrorHandler, agentruntime.EventProviderRetry: runtimeEventProviderRetryHandler, + agentruntime.EventPermissionRequest: runtimeEventPermissionRequestHandler, + agentruntime.EventPermissionResolved: runtimeEventPermissionResolvedHandler, agentruntime.EventCompactDone: runtimeEventCompactDoneHandler, agentruntime.EventCompactError: runtimeEventCompactErrorHandler, } @@ -882,6 +984,7 @@ func runtimeEventAgentDoneHandler(a *App, event agentruntime.RuntimeEvent) bool a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ActiveRunID = "" + a.pendingPermission = nil a.clearRunProgress() if strings.TrimSpace(a.state.ExecutionError) == "" { a.state.StatusText = statusReady @@ -899,6 +1002,7 @@ func runtimeEventRunCanceledHandler(a *App, event agentruntime.RuntimeEvent) boo a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ActiveRunID = "" + a.pendingPermission = nil a.state.ExecutionError = "" a.state.StatusText = statusCanceled a.clearRunProgress() @@ -913,6 +1017,7 @@ func runtimeEventErrorHandler(a *App, event agentruntime.RuntimeEvent) bool { a.state.StreamingReply = false a.state.CurrentTool = "" a.state.ActiveRunID = "" + a.pendingPermission = nil a.clearRunProgress() if payload, ok := event.Payload.(string); ok { a.state.ExecutionError = payload @@ -932,6 +1037,75 @@ func runtimeEventProviderRetryHandler(a *App, event agentruntime.RuntimeEvent) b return false } +// runtimeEventPermissionRequestHandler 处理 permission_request 事件并激活审批面板。 +func runtimeEventPermissionRequestHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := parsePermissionRequestPayload(event.Payload) + if !ok { + return false + } + + if a.pendingPermission != nil { + currentRequestID := strings.TrimSpace(a.pendingPermission.Request.RequestID) + nextRequestID := strings.TrimSpace(payload.RequestID) + if currentRequestID != "" && currentRequestID != nextRequestID && !a.pendingPermission.Submitting { + a.deferredEventCmd = runResolvePermission(a.runtime, currentRequestID, agentruntime.PermissionResolutionReject) + a.appendActivity( + "permission", + "Auto-rejected superseded permission request", + currentRequestID, + false, + ) + } + } + + a.pendingPermission = &permissionPromptState{ + Request: payload, + Selected: 0, + Submitting: false, + } + a.focus = panelInput + a.applyFocus() + a.state.StatusText = statusPermissionRequired + a.state.ExecutionError = "" + a.appendActivity( + "permission", + "Permission request", + fmt.Sprintf("%s -> %s", fallbackText(payload.ToolName, "tool"), fallbackText(payload.Target, "(empty target)")), + false, + ) + a.refreshPermissionPromptLayout() + return false +} + +// runtimeEventPermissionResolvedHandler 处理 permission_resolved 事件并清理审批面板状态。 +func runtimeEventPermissionResolvedHandler(a *App, event agentruntime.RuntimeEvent) bool { + payload, ok := parsePermissionResolvedPayload(event.Payload) + if !ok { + return false + } + + if a.pendingPermission != nil && a.pendingPermission.Request.RequestID == payload.RequestID { + a.pendingPermission = nil + } + a.state.StatusText = fmt.Sprintf("Permission %s", fallbackText(payload.ResolvedAs, "resolved")) + a.appendActivity( + "permission", + "Permission resolved", + fmt.Sprintf("%s (%s)", fallbackText(payload.Decision, "unknown"), fallbackText(payload.RememberScope, "once")), + false, + ) + a.refreshPermissionPromptLayout() + return false +} + +// refreshPermissionPromptLayout 在布局已初始化时刷新权限面板相关排版。 +func (a *App) refreshPermissionPromptLayout() { + if a.width <= 0 || a.height <= 0 { + return + } + a.applyComponentLayout(false) +} + // runtimeEventCompactDoneHandler 处理 compact 完成事件。 func runtimeEventCompactDoneHandler(a *App, event agentruntime.RuntimeEvent) bool { payload, ok := event.Payload.(agentruntime.CompactDonePayload) @@ -1329,12 +1503,10 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.sessions.SetSize(sidebarBodyWidth, sidebarBodyHeight) a.transcript.Width = max(24, lay.rightWidth) a.resizeCommandMenu() - menuHeight := a.commandMenuHeight(max(24, lay.rightWidth)) - activityHeight := a.activityPreviewHeight() a.input.SetWidth(a.composerInnerWidth(lay.rightWidth)) a.input.SetHeight(a.composerHeight()) - promptHeight := lipgloss.Height(a.renderPrompt(a.transcript.Width)) - a.transcript.Height = max(6, lay.rightHeight-activityHeight-menuHeight-promptHeight) + transcriptHeight, activityHeight, _, _ := a.waterfallMetrics(a.transcript.Width, lay.rightHeight) + a.transcript.Height = transcriptHeight if activityHeight > 0 { panelStyle := a.styles.panelFocused @@ -1353,6 +1525,12 @@ func (a *App) applyComponentLayout(rebuildTranscript bool) { a.providerPicker.SetSize(max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.rightHeight-10, 6, 10))) a.modelPicker.SetSize(max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), max(4, tuiutils.Clamp(lay.rightHeight-10, 6, 10))) + helpPickerMaxHeight := max(8, lay.rightHeight-6) + helpPickerDesiredHeight := (len(a.helpPicker.Items()) * 3) + 1 + a.helpPicker.SetSize( + max(24, tuiutils.Clamp(lay.rightWidth-14, 28, 52)), + max(6, tuiutils.Clamp(helpPickerDesiredHeight, 6, helpPickerMaxHeight)), + ) a.fileBrowser.SetHeight(max(6, tuiutils.Clamp(lay.rightHeight-8, 8, 16))) if rebuildTranscript || prevTranscriptWidth != a.transcript.Width { a.rebuildTranscript() @@ -1500,6 +1678,50 @@ func (a *App) handleImmediateSlashCommand(input string) (bool, tea.Cmd) { } } +// runSlashCommandSelection 根据 /help 弹层选中的命令执行对应 slash 行为。 +func (a *App) runSlashCommandSelection(command string) tea.Cmd { + command = strings.ToLower(strings.TrimSpace(command)) + if command == "" { + return nil + } + + if handled, cmd := a.handleImmediateSlashCommand(command); handled { + return cmd + } + + switch command { + case slashCommandHelp: + a.refreshHelpPicker() + a.openHelpPicker() + return nil + case slashCommandProvider: + if err := a.refreshProviderPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh providers", err.Error(), true) + return nil + } + a.openProviderPicker() + return nil + case slashCommandModelPick: + if err := a.refreshModelPicker(); err != nil { + a.state.ExecutionError = err.Error() + a.state.StatusText = err.Error() + a.appendActivity("system", "Failed to refresh models", err.Error(), true) + return nil + } + a.openModelPicker() + return a.requestModelCatalogRefresh(a.state.CurrentProvider) + default: + a.state.StatusText = statusApplyingCommand + a.state.ExecutionError = "" + if isWorkspaceSlashCommand(command) { + return runSessionWorkdirCommand(a.runtime, a.state.ActiveSessionID, a.state.CurrentWorkdir, command) + } + return runLocalCommand(a.configManager, a.providerSvc, a.currentStatusSnapshot(), command) + } +} + func (a App) currentStatusSnapshot() tuistatus.Snapshot { return tuistatus.BuildFromUIState( a.state, @@ -1522,6 +1744,7 @@ func (a *App) startDraftSession() { a.state.ToolStates = nil a.state.RunContext = tuistate.ContextWindowState{} a.state.TokenUsage = tuistate.TokenUsageState{} + a.pendingPermission = nil a.clearRunProgress() a.input.Reset() a.state.InputText = "" @@ -1567,6 +1790,28 @@ func runAgent(runtime agentruntime.Runtime, runID string, sessionID string, work ) } +// runResolvePermission 提交一次权限审批决定到 runtime。 +func runResolvePermission( + runtime agentruntime.Runtime, + requestID string, + decision agentruntime.PermissionResolutionDecision, +) tea.Cmd { + return tuiservices.RunResolvePermissionCmd( + runtime, + agentruntime.PermissionResolutionInput{ + RequestID: strings.TrimSpace(requestID), + Decision: decision, + }, + func(input agentruntime.PermissionResolutionInput, err error) tea.Msg { + return permissionResolutionFinishedMsg{ + RequestID: input.RequestID, + Decision: input.Decision, + Err: err, + } + }, + ) +} + func runSessionWorkdirCommand( runtime agentruntime.Runtime, sessionID string, diff --git a/internal/tui/core/app/update_permission_test.go b/internal/tui/core/app/update_permission_test.go new file mode 100644 index 00000000..d340f226 --- /dev/null +++ b/internal/tui/core/app/update_permission_test.go @@ -0,0 +1,413 @@ +package tui + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + + agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" + tuistate "neo-code/internal/tui/state" +) + +type permissionTestRuntime struct { + resolveErr error + lastResolved agentruntime.PermissionResolutionInput +} + +func (r *permissionTestRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (r *permissionTestRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (r *permissionTestRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + r.lastResolved = input + return r.resolveErr +} + +func (r *permissionTestRuntime) CancelActiveRun() bool { + return false +} + +func (r *permissionTestRuntime) Events() <-chan agentruntime.RuntimeEvent { + ch := make(chan agentruntime.RuntimeEvent) + close(ch) + return ch +} + +func (r *permissionTestRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return nil, nil +} + +func (r *permissionTestRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func (r *permissionTestRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + return agentsession.Session{}, nil +} + +func newPermissionTestApp(runtime agentruntime.Runtime) *App { + input := textarea.New() + spin := spinner.New() + sessionList := list.New([]list.Item{}, list.NewDefaultDelegate(), 0, 0) + app := &App{ + state: tuistate.UIState{ + Focus: panelInput, + }, + appServices: appServices{ + runtime: runtime, + }, + appComponents: appComponents{ + keys: newKeyMap(), + spinner: spin, + sessions: sessionList, + input: input, + transcript: viewport.New(0, 0), + activity: viewport.New(0, 0), + }, + appRuntimeState: appRuntimeState{ + nowFn: time.Now, + codeCopyBlocks: map[int]string{}, + focus: panelInput, + activities: []tuistate.ActivityEntry{ + {Kind: "test", Title: "seed"}, + }, + }, + } + return app +} + +func TestUpdatePendingPermissionInputSelectAndSubmit(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-1"}, + Selected: 0, + } + + cmd, handled := app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyDown}) + if !handled || cmd != nil { + t.Fatalf("expected handled down key without cmd, handled=%v cmd=%v", handled, cmd) + } + if app.pendingPermission.Selected != 1 { + t.Fatalf("expected selection moved to 1, got %d", app.pendingPermission.Selected) + } + + cmd, handled = app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyUp}) + if !handled || cmd != nil { + t.Fatalf("expected handled up key without cmd, handled=%v cmd=%v", handled, cmd) + } + if app.pendingPermission.Selected != 0 { + t.Fatalf("expected selection moved back to 0, got %d", app.pendingPermission.Selected) + } + + cmd, handled = app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + if !handled || cmd != nil { + t.Fatalf("expected unknown shortcut to be consumed without cmd, handled=%v cmd=%v", handled, cmd) + } + + cmd, handled = app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyEnter}) + if !handled || cmd == nil { + t.Fatalf("expected enter key to submit permission decision, handled=%v cmd=%v", handled, cmd) + } + + msg := cmd() + done, ok := msg.(permissionResolutionFinishedMsg) + if !ok { + t.Fatalf("expected permissionResolutionFinishedMsg, got %T", msg) + } + if done.RequestID != "perm-1" || done.Decision != agentruntime.PermissionResolutionAllowOnce { + t.Fatalf("unexpected submitted decision: %+v", done) + } + if runtime.lastResolved.Decision != agentruntime.PermissionResolutionAllowOnce { + t.Fatalf("runtime decision mismatch: %+v", runtime.lastResolved) + } +} + +func TestUpdatePendingPermissionInputWithoutPendingState(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + cmd, handled := app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyEnter}) + if handled || cmd != nil { + t.Fatalf("expected no handling when pending permission is nil, handled=%v cmd=%v", handled, cmd) + } +} + +func TestUpdatePendingPermissionInputShortcut(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-2"}, + Selected: 0, + } + + cmd, handled := app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'n'}}) + if !handled || cmd == nil { + t.Fatalf("expected shortcut n to trigger submit, handled=%v cmd=%v", handled, cmd) + } + msg := cmd() + done, ok := msg.(permissionResolutionFinishedMsg) + if !ok { + t.Fatalf("expected permissionResolutionFinishedMsg, got %T", msg) + } + if done.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("expected reject decision, got %q", done.Decision) + } +} + +func TestUpdatePendingPermissionInputSubmittingConsumesInput(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-3"}, + Selected: 0, + Submitting: true, + } + cmd, handled := app.updatePendingPermissionInput(tea.KeyMsg{Type: tea.KeyDown}) + if !handled || cmd != nil { + t.Fatalf("expected submitting state to consume key without cmd, handled=%v cmd=%v", handled, cmd) + } +} + +func TestSubmitPermissionDecisionValidation(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + if cmd := app.submitPermissionDecision(agentruntime.PermissionResolutionAllowOnce); cmd != nil { + t.Fatalf("expected nil cmd when no pending permission") + } + + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: " "}, + Selected: 0, + } + if cmd := app.submitPermissionDecision(agentruntime.PermissionResolutionAllowOnce); cmd != nil { + t.Fatalf("expected nil cmd for empty request id") + } +} + +func TestRuntimePermissionEventHandlers(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + requestEvent := agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionRequest, + Payload: agentruntime.PermissionRequestPayload{ + RequestID: "perm-4", + ToolName: "bash", + Target: "git status", + }, + } + if dirty := runtimeEventPermissionRequestHandler(app, requestEvent); dirty { + t.Fatalf("permission request should not mark transcript dirty") + } + if app.pendingPermission == nil || app.pendingPermission.Request.RequestID != "perm-4" { + t.Fatalf("expected pending permission to be recorded") + } + + resolvedEvent := agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionResolved, + Payload: agentruntime.PermissionResolvedPayload{ + RequestID: "perm-4", + Decision: "allow", + RememberScope: "once", + ResolvedAs: "approved", + }, + } + if dirty := runtimeEventPermissionResolvedHandler(app, resolvedEvent); dirty { + t.Fatalf("permission resolved should not mark transcript dirty") + } + if app.pendingPermission != nil { + t.Fatalf("expected pending permission to be cleared after resolved") + } +} + +func TestUpdatePermissionResolutionFinishedMessage(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-5"}, + Selected: 0, + Submitting: true, + } + app.state.IsAgentRunning = true + app.state.IsCompacting = true + app.state.StatusText = "busy" + + model, _ := app.Update(permissionResolutionFinishedMsg{ + RequestID: "perm-5", + Decision: agentruntime.PermissionResolutionAllowOnce, + Err: errors.New("network"), + }) + next := model.(App) + if next.pendingPermission == nil || next.pendingPermission.Submitting { + t.Fatalf("expected pending permission to remain and reset submitting on error") + } + if next.state.ExecutionError == "" { + t.Fatalf("expected execution error after failed permission submit") + } +} + +func TestUpdatePermissionResolutionFinishedMessageSuccessClearsPendingPermission(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-5-success"}, + Selected: 0, + Submitting: true, + } + + model, _ := app.Update(permissionResolutionFinishedMsg{ + RequestID: "perm-5-success", + Decision: agentruntime.PermissionResolutionAllowOnce, + }) + next := model.(App) + if next.pendingPermission != nil { + t.Fatalf("expected pending permission to be cleared on success") + } + if next.state.StatusText != statusPermissionSubmitted { + t.Fatalf("expected submitted status text, got %q", next.state.StatusText) + } +} + +func TestUpdateRuntimeClosedClearsPendingPermission(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-6"}, + } + model, _ := app.Update(RuntimeClosedMsg{}) + next := model.(App) + if next.pendingPermission != nil { + t.Fatalf("expected runtime closed to clear pending permission") + } +} + +func TestRuntimePermissionRequestHandlerAutoRejectsSupersededRequest(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-old"}, + Selected: 1, + } + + event := agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionRequest, + Payload: agentruntime.PermissionRequestPayload{ + RequestID: "perm-new", + ToolName: "bash", + Target: "pwd", + }, + } + if dirty := runtimeEventPermissionRequestHandler(app, event); dirty { + t.Fatalf("permission request should not mark transcript dirty") + } + if app.pendingPermission == nil || app.pendingPermission.Request.RequestID != "perm-new" { + t.Fatalf("expected latest permission request to replace old one") + } + if app.deferredEventCmd == nil { + t.Fatalf("expected superseded request to schedule auto-reject command") + } + + msg := app.deferredEventCmd() + done, ok := msg.(permissionResolutionFinishedMsg) + if !ok { + t.Fatalf("expected permissionResolutionFinishedMsg, got %T", msg) + } + if done.RequestID != "perm-old" || done.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("unexpected auto-reject payload: %+v", done) + } + if runtime.lastResolved.RequestID != "perm-old" || runtime.lastResolved.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("unexpected runtime resolve input: %+v", runtime.lastResolved) + } +} + +func TestRuntimePermissionRequestHandlerDoesNotAutoRejectSubmittingRequest(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-old"}, + Submitting: true, + } + + runtimeEventPermissionRequestHandler(app, agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionRequest, + Payload: agentruntime.PermissionRequestPayload{ + RequestID: "perm-new", + }, + }) + if app.deferredEventCmd != nil { + t.Fatalf("expected no auto-reject command when current request is already submitting") + } +} + +func TestHandleRuntimeEventQueuesDeferredCommand(t *testing.T) { + runtime := &permissionTestRuntime{} + app := newPermissionTestApp(runtime) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-old"}, + } + + model, cmd := app.Update(RuntimeMsg{Event: agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionRequest, + Payload: agentruntime.PermissionRequestPayload{ + RequestID: "perm-new", + }, + }}) + next := model.(App) + if next.deferredEventCmd != nil { + t.Fatalf("expected deferred event cmd to be consumed during update") + } + if cmd == nil { + t.Fatalf("expected runtime update to batch deferred command") + } + msg := cmd() + batch, ok := msg.(tea.BatchMsg) + if !ok { + t.Fatalf("expected deferred command batch, got %T", msg) + } + if len(batch) == 0 { + t.Fatalf("expected deferred command batch to contain work") + } + if _, ok := batch[0]().(permissionResolutionFinishedMsg); !ok { + t.Fatalf("expected deferred batch command to resolve permission") + } + if runtime.lastResolved.RequestID != "perm-old" || runtime.lastResolved.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("expected deferred auto-reject to run, got %+v", runtime.lastResolved) + } +} + +func TestRuntimePermissionResolvedHandlerUsesExactRequestIDMatch(t *testing.T) { + app := newPermissionTestApp(&permissionTestRuntime{}) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "Perm-1"}, + } + + runtimeEventPermissionResolvedHandler(app, agentruntime.RuntimeEvent{ + Type: agentruntime.EventPermissionResolved, + Payload: agentruntime.PermissionResolvedPayload{ + RequestID: "perm-1", + }, + }) + if app.pendingPermission == nil { + t.Fatalf("expected mismatched request id case to keep pending permission") + } +} + +func TestRunResolvePermissionForwardsRuntimeError(t *testing.T) { + runtime := &permissionTestRuntime{resolveErr: errors.New("resolve failed")} + cmd := runResolvePermission(runtime, "perm-7", agentruntime.PermissionResolutionReject) + msg := cmd() + done, ok := msg.(permissionResolutionFinishedMsg) + if !ok { + t.Fatalf("expected permissionResolutionFinishedMsg, got %T", msg) + } + if done.Err == nil || done.Err.Error() != "resolve failed" { + t.Fatalf("expected forwarded resolve error, got %#v", done.Err) + } + if runtime.lastResolved.RequestID != "perm-7" || runtime.lastResolved.Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("unexpected runtime resolve input: %+v", runtime.lastResolved) + } +} diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go new file mode 100644 index 00000000..ae196767 --- /dev/null +++ b/internal/tui/core/app/update_test.go @@ -0,0 +1,1331 @@ +package tui + +import ( + "context" + "errors" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "neo-code/internal/config" + providertypes "neo-code/internal/provider/types" + agentruntime "neo-code/internal/runtime" + agentsession "neo-code/internal/session" + "neo-code/internal/tools" + tuibootstrap "neo-code/internal/tui/bootstrap" + tuiservices "neo-code/internal/tui/services" + tuistate "neo-code/internal/tui/state" +) + +type stubProviderService struct { + providers []config.ProviderCatalogItem + models []config.ModelDescriptor +} + +func (s stubProviderService) ListProviders(ctx context.Context) ([]config.ProviderCatalogItem, error) { + return s.providers, nil +} + +func (s stubProviderService) SelectProvider(ctx context.Context, providerID string) (config.ProviderSelection, error) { + modelID := "" + if len(s.models) > 0 { + modelID = s.models[0].ID + } + return config.ProviderSelection{ProviderID: providerID, ModelID: modelID}, nil +} + +func (s stubProviderService) ListModels(ctx context.Context) ([]config.ModelDescriptor, error) { + return s.models, nil +} + +func (s stubProviderService) ListModelsSnapshot(ctx context.Context) ([]config.ModelDescriptor, error) { + return s.models, nil +} + +func (s stubProviderService) SetCurrentModel(ctx context.Context, modelID string) (config.ProviderSelection, error) { + providerID := "" + if len(s.providers) > 0 { + providerID = s.providers[0].ID + } + return config.ProviderSelection{ProviderID: providerID, ModelID: modelID}, nil +} + +type stubRuntime struct { + events chan agentruntime.RuntimeEvent + resolveCalls []agentruntime.PermissionResolutionInput + resolveErr error + cancelInvoked bool +} + +func newStubRuntime() *stubRuntime { + return &stubRuntime{events: make(chan agentruntime.RuntimeEvent)} +} + +func (s *stubRuntime) Run(ctx context.Context, input agentruntime.UserInput) error { + return nil +} + +func (s *stubRuntime) Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) { + return agentruntime.CompactResult{}, nil +} + +func (s *stubRuntime) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + s.resolveCalls = append(s.resolveCalls, input) + return s.resolveErr +} + +func (s *stubRuntime) CancelActiveRun() bool { + s.cancelInvoked = true + return true +} + +func (s *stubRuntime) Events() <-chan agentruntime.RuntimeEvent { + return s.events +} + +func (s *stubRuntime) ListSessions(ctx context.Context) ([]agentsession.Summary, error) { + return nil, nil +} + +func (s *stubRuntime) LoadSession(ctx context.Context, id string) (agentsession.Session, error) { + return agentsession.NewWithWorkdir("draft", ""), nil +} + +func (s *stubRuntime) SetSessionWorkdir(ctx context.Context, sessionID string, workdir string) (agentsession.Session, error) { + return agentsession.NewWithWorkdir("draft", workdir), nil +} + +func newTestApp(t *testing.T) (App, *stubRuntime) { + t.Helper() + + cfg := config.DefaultConfig() + cfg.Workdir = t.TempDir() + if len(cfg.Providers) > 0 { + cfg.SelectedProvider = cfg.Providers[0].Name + cfg.CurrentModel = cfg.Providers[0].Model + } + + manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + var providers []config.ProviderCatalogItem + var models []config.ModelDescriptor + if len(cfg.Providers) > 0 { + provider := cfg.Providers[0] + providers = []config.ProviderCatalogItem{ + { + ID: provider.Name, + Name: provider.Name, + Description: "test provider", + Models: []config.ModelDescriptor{ + {ID: provider.Model, Name: provider.Model}, + }, + }, + } + models = []config.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + } + + runtime := newStubRuntime() + app, err := newApp(tuibootstrap.Container{ + Config: *cfg, + ConfigManager: manager, + Runtime: runtime, + ProviderService: stubProviderService{providers: providers, models: models}, + }) + if err != nil { + t.Fatalf("newApp() error = %v", err) + } + + return app, runtime +} + +func TestAppUpdateBasic(t *testing.T) { + app, _ := newTestApp(t) + + windowMsg := tea.WindowSizeMsg{Width: 100, Height: 30} + model, cmd := app.Update(windowMsg) + if model == nil { + t.Error("Update returned nil model for WindowSizeMsg") + } + app = model.(App) + if cmd != nil { + t.Error("Update returned non-nil cmd for WindowSizeMsg") + } + + app.state.StatusText = "" + closedMsg := RuntimeClosedMsg{} + model, cmd = app.Update(closedMsg) + if model == nil { + t.Error("Update returned nil model for RuntimeClosedMsg") + } + app = model.(App) + if cmd != nil { + t.Error("Update returned non-nil cmd for RuntimeClosedMsg") + } + if app.state.StatusText != statusRuntimeClosed { + t.Errorf("Expected status %s, got %s", statusRuntimeClosed, app.state.StatusText) + } + + runErrMsg := runFinishedMsg{Err: errors.New("test error")} + model, cmd = app.Update(runErrMsg) + if model == nil { + t.Error("Update returned nil model for runFinishedMsg with error") + } + app = model.(App) + if cmd != nil { + t.Error("Update returned non-nil cmd for runFinishedMsg with error") + } + + canceledMsg := runFinishedMsg{Err: context.Canceled} + model, cmd = app.Update(canceledMsg) + if model == nil { + t.Error("Update returned nil model for runFinishedMsg with canceled error") + } + app = model.(App) + if cmd != nil { + t.Error("Update returned non-nil cmd for runFinishedMsg with canceled error") + } +} + +func TestParsePermissionShortcutFromKeyInput(t *testing.T) { + if decision, ok := parsePermissionShortcut("y"); !ok || decision != agentruntime.PermissionResolutionAllowOnce { + t.Fatalf("expected allow_once, got %v (ok=%v)", decision, ok) + } + if decision, ok := parsePermissionShortcut("a"); !ok || decision != agentruntime.PermissionResolutionAllowSession { + t.Fatalf("expected allow_session, got %v (ok=%v)", decision, ok) + } + if decision, ok := parsePermissionShortcut("n"); !ok || decision != agentruntime.PermissionResolutionReject { + t.Fatalf("expected reject, got %v (ok=%v)", decision, ok) + } + if _, ok := parsePermissionShortcut("x"); ok { + t.Fatalf("expected unsupported key to return false") + } +} + +func TestRuntimeEventPermissionRequestHandler(t *testing.T) { + app, _ := newTestApp(t) + + payload := agentruntime.PermissionRequestPayload{ + RequestID: "perm-1", + ToolName: "bash", + Operation: "write", + Target: "file.txt", + } + handled := runtimeEventPermissionRequestHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if handled { + t.Fatalf("expected handler to return false") + } + if app.pendingPermission == nil || app.pendingPermission.Request.RequestID != "perm-1" { + t.Fatalf("expected pending permission request to be set") + } + if app.state.StatusText != statusPermissionRequired { + t.Fatalf("expected permission required status, got %s", app.state.StatusText) + } +} + +func TestRuntimeEventPermissionResolvedHandler(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-2"}, + } + + payload := agentruntime.PermissionResolvedPayload{ + RequestID: "perm-2", + ToolName: "bash", + Decision: "allow", + ResolvedAs: "approved", + } + handled := runtimeEventPermissionResolvedHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if handled { + t.Fatalf("expected handler to return false") + } + if app.pendingPermission != nil { + t.Fatalf("expected pending permission to be cleared") + } + if app.state.StatusText != "Permission approved" { + t.Fatalf("expected resolved status text, got %s", app.state.StatusText) + } +} + +func TestUpdatePermissionResolveFlow(t *testing.T) { + app, runtime := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-3"}, + } + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")}) + if model == nil { + t.Fatalf("expected non-nil model") + } + app = model.(App) + if cmd == nil { + t.Fatalf("expected command to resolve permission") + } + if app.state.StatusText != statusPermissionSubmitting { + t.Fatalf("expected submitting status, got %s", app.state.StatusText) + } + + msg := cmd() + if len(runtime.resolveCalls) != 1 || runtime.resolveCalls[0].RequestID != "perm-3" { + t.Fatalf("expected ResolvePermission to be called") + } + if runtime.resolveCalls[0].Decision != agentruntime.PermissionResolutionAllowOnce { + t.Fatalf("unexpected decision forwarded: %s", runtime.resolveCalls[0].Decision) + } + + next, _ := app.Update(msg) + app = next.(App) + if app.pendingPermission != nil { + t.Fatalf("expected pending permission to be cleared after submit") + } + if app.state.StatusText != statusPermissionSubmitted { + t.Fatalf("expected submitted status, got %s", app.state.StatusText) + } +} + +func TestUpdatePermissionResolvedError(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-4"}, + Submitting: true, + } + + model, _ := app.Update(permissionResolutionFinishedMsg{ + RequestID: "perm-4", + Decision: agentruntime.PermissionResolutionAllowOnce, + Err: errors.New("boom"), + }) + app = model.(App) + + if app.pendingPermission == nil || app.pendingPermission.Submitting { + t.Fatalf("expected pending permission to remain but leave submitting state") + } + if app.state.StatusText != "boom" { + t.Fatalf("expected failure status, got %s", app.state.StatusText) + } +} + +func TestRunResolvePermissionCommand(t *testing.T) { + runtime := newStubRuntime() + cmd := runResolvePermission(runtime, "perm-5", agentruntime.PermissionResolutionAllowSession) + if cmd == nil { + t.Fatalf("expected command") + } + msg := cmd() + resolved, ok := msg.(permissionResolutionFinishedMsg) + if !ok { + t.Fatalf("expected permissionResolutionFinishedMsg, got %T", msg) + } + if resolved.RequestID != "perm-5" || resolved.Decision != agentruntime.PermissionResolutionAllowSession { + t.Fatalf("unexpected resolved msg: %#v", resolved) + } + if len(runtime.resolveCalls) != 1 { + t.Fatalf("expected resolve call recorded") + } +} + +func TestRenderPermissionPromptInUpdateFlow(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{ + RequestID: "perm-6", + ToolName: "bash", + Operation: "write", + Target: "file.txt", + }, + } + got := app.renderPermissionPrompt() + if !strings.Contains(got, "Permission request: bash (write)") { + t.Fatalf("expected permission prompt header, got %q", got) + } +} + +func TestUpdatePermissionResolutionFinishedMsgIgnoresMismatch(t *testing.T) { + app, _ := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-7"}, + } + model, cmd := app.Update(permissionResolutionFinishedMsg{ + RequestID: "perm-8", + Decision: agentruntime.PermissionResolutionAllowOnce, + }) + if model == nil { + t.Fatalf("expected model") + } + app = model.(App) + if cmd != nil { + t.Fatalf("expected nil cmd") + } + if app.pendingPermission == nil || app.pendingPermission.Request.RequestID != "perm-7" { + t.Fatalf("expected pending permission to remain") + } +} + +func TestRuntimeEventPermissionRequestUsesToolName(t *testing.T) { + app, _ := newTestApp(t) + payload := agentruntime.PermissionRequestPayload{ + RequestID: "perm-9", + ToolName: "webfetch", + } + runtimeEventPermissionRequestHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if app.pendingPermission == nil || app.pendingPermission.Request.ToolName != "webfetch" { + t.Fatalf("expected pending permission tool to be set") + } +} + +func TestUpdatePermissionRejectFlow(t *testing.T) { + app, runtime := newTestApp(t) + app.pendingPermission = &permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{RequestID: "perm-10"}, + } + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}) + if cmd == nil { + t.Fatalf("expected resolve cmd") + } + app = model.(App) + msg := cmd() + next, _ := app.Update(msg) + app = next.(App) + if len(runtime.resolveCalls) != 1 || runtime.resolveCalls[0].Decision != agentruntime.PermissionResolutionReject { + t.Fatalf("expected reject decision to be submitted") + } + if app.state.StatusText != statusPermissionSubmitted { + t.Fatalf("expected submitted status, got %s", app.state.StatusText) + } +} + +func TestRuntimeEventToolResultHandlerUpdatesMessages(t *testing.T) { + app, _ := newTestApp(t) + result := tools.ToolResult{ + Name: "bash", + Content: "ok", + IsError: false, + ToolCallID: "tool-1", + } + handled := runtimeEventToolResultHandler(&app, agentruntime.RuntimeEvent{Payload: result}) + if !handled { + t.Fatalf("expected handler to return true") + } + last := app.activeMessages[len(app.activeMessages)-1] + if last.Role != roleTool || last.Content != "ok" { + t.Fatalf("unexpected tool message: %#v", last) + } +} + +func TestRuntimeEventToolResultHandlerError(t *testing.T) { + app, _ := newTestApp(t) + result := tools.ToolResult{ + Name: "bash", + Content: "boom", + IsError: true, + ToolCallID: "tool-2", + } + handled := runtimeEventToolResultHandler(&app, agentruntime.RuntimeEvent{Payload: result}) + if !handled { + t.Fatalf("expected handler to return true") + } + if app.state.StatusText != statusToolError { + t.Fatalf("expected tool error status, got %s", app.state.StatusText) + } +} + +func TestRuntimeEventAgentDoneHandlerAppendsMessage(t *testing.T) { + app, _ := newTestApp(t) + payload := providertypes.Message{Role: roleAssistant, Content: "done"} + handled := runtimeEventAgentDoneHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if !handled { + t.Fatalf("expected handler to return true") + } + if len(app.activeMessages) == 0 { + t.Fatalf("expected message appended") + } +} + +func TestParseFenceOpenLine(t *testing.T) { + info, ok := parseFenceOpenLine("```go") + if !ok || info != "go" { + t.Fatalf("expected fence info, got %q ok=%v", info, ok) + } + info, ok = parseFenceOpenLine(" not a fence") + if ok || info != "" { + t.Fatalf("expected no fence") + } +} + +func TestIsFenceCloseLine(t *testing.T) { + if !isFenceCloseLine("```") { + t.Fatalf("expected fence close") + } + if isFenceCloseLine("```go") { + t.Fatalf("expected not fence close") + } +} + +func TestIsIndentedCodeLine(t *testing.T) { + if !isIndentedCodeLine("\tcode") { + t.Fatalf("expected tab-indented code") + } + if !isIndentedCodeLine(" code") { + t.Fatalf("expected space-indented code") + } + if isIndentedCodeLine("code") { + t.Fatalf("expected non-indented line") + } +} + +func TestTrimCodeIndent(t *testing.T) { + if got := trimCodeIndent("\tcode"); got != "code" { + t.Fatalf("expected trimmed tab indent, got %q", got) + } + if got := trimCodeIndent(" code"); got != "code" { + t.Fatalf("expected trimmed space indent, got %q", got) + } + if got := trimCodeIndent("code"); got != "code" { + t.Fatalf("expected unchanged line, got %q", got) + } +} + +func TestSplitMarkdownSegmentsFenced(t *testing.T) { + content := "hello\n```go\nfmt.Println(\"ok\")\n```\nworld" + segments := splitMarkdownSegments(content) + if len(segments) < 2 { + t.Fatalf("expected multiple segments, got %d", len(segments)) + } + if segments[1].Kind != markdownSegmentCode || segments[1].Code == "" { + t.Fatalf("expected code segment") + } +} + +func TestSplitMarkdownSegmentsIndented(t *testing.T) { + content := "hello\n code line\nworld" + segments := splitMarkdownSegments(content) + if len(segments) < 2 { + t.Fatalf("expected multiple segments, got %d", len(segments)) + } + foundCode := false + for _, seg := range segments { + if seg.Kind == markdownSegmentCode && seg.Code != "" { + foundCode = true + } + } + if !foundCode { + t.Fatalf("expected indented code segment") + } +} + +func TestSplitIndentedCodeSegmentsDetectsCodeFeaturesInCodeMode(t *testing.T) { + content := "func main() {\nreturn 1\n}\nplain text" + segments := splitIndentedCodeSegments(content) + if len(segments) < 2 { + t.Fatalf("expected code and text segments, got %d", len(segments)) + } + if segments[0].Kind != markdownSegmentCode { + t.Fatalf("expected first segment to be code") + } + if !strings.Contains(segments[0].Code, "return 1") { + t.Fatalf("expected code segment to include return statement, got %q", segments[0].Code) + } +} + +func TestExtractFencedCodeBlocks(t *testing.T) { + content := "text\n```go\nfmt.Println(\"ok\")\n```\nend" + blocks := extractFencedCodeBlocks(content) + if len(blocks) != 1 || blocks[0] == "" { + t.Fatalf("expected one code block") + } +} + +func TestParseCopyCodeButton(t *testing.T) { + id, start, end, ok := parseCopyCodeButton("[Copy code #12]") + if !ok || id != 12 || start >= end { + t.Fatalf("unexpected parse result: id=%d start=%d end=%d ok=%v", id, start, end, ok) + } + if _, _, _, ok := parseCopyCodeButton("no button"); ok { + t.Fatalf("expected no button parse") + } +} + +func TestCopyCodeBlockByIDSuccess(t *testing.T) { + app, _ := newTestApp(t) + + var got string + originalClipboard := clipboardWriteAll + clipboardWriteAll = func(text string) error { + got = text + return nil + } + defer func() { clipboardWriteAll = originalClipboard }() + + app.setCodeCopyBlocks([]copyCodeButtonBinding{{ID: 1, Code: "code"}}) + ok := app.copyCodeBlockByID(1) + if !ok { + t.Fatalf("expected handled copy") + } + if got != "code" { + t.Fatalf("expected clipboard content, got %q", got) + } + if app.state.StatusText == "" { + t.Fatalf("expected status text to be set") + } +} + +func TestCopyCodeBlockByIDMissing(t *testing.T) { + app, _ := newTestApp(t) + + ok := app.copyCodeBlockByID(99) + if !ok { + t.Fatalf("expected handled copy") + } + if app.state.StatusText != statusCodeCopyError { + t.Fatalf("expected error status, got %s", app.state.StatusText) + } +} + +func TestCopyCodeBlockByIDClipboardError(t *testing.T) { + app, _ := newTestApp(t) + + originalClipboard := clipboardWriteAll + clipboardWriteAll = func(text string) error { + return errors.New("fail") + } + defer func() { clipboardWriteAll = originalClipboard }() + + app.setCodeCopyBlocks([]copyCodeButtonBinding{{ID: 2, Code: "code"}}) + ok := app.copyCodeBlockByID(2) + if !ok { + t.Fatalf("expected handled copy") + } + if app.state.StatusText != statusCodeCopyError { + t.Fatalf("expected error status, got %s", app.state.StatusText) + } +} + +func TestIsWorkspaceCommandInput(t *testing.T) { + if !isWorkspaceCommandInput("& ls -la") { + t.Fatalf("expected workspace command prefix to be detected") + } + if isWorkspaceCommandInput("ls -la") { + t.Fatalf("expected non-workspace command to be false") + } +} + +func TestExtractWorkspaceCommand(t *testing.T) { + command, err := extractWorkspaceCommand("& git status") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if command != "git status" { + t.Fatalf("expected command to be extracted, got %q", command) + } + + if _, err := extractWorkspaceCommand("&"); err == nil { + t.Fatalf("expected error for empty command") + } + if _, err := extractWorkspaceCommand("git status"); err == nil { + t.Fatalf("expected error for missing prefix") + } +} + +func TestFormatWorkspaceCommandResult(t *testing.T) { + output := "clean\n" + got := formatWorkspaceCommandResult("git status", output, nil) + if !strings.Contains(got, "Command: & git status") { + t.Fatalf("expected success header, got %q", got) + } + if !strings.Contains(got, "clean") { + t.Fatalf("expected output to be included") + } + + errResult := formatWorkspaceCommandResult("git status", "", errors.New("boom")) + if !strings.Contains(errResult, "Command Failed: & git status") { + t.Fatalf("expected failure header, got %q", errResult) + } + if !strings.Contains(errResult, "boom") { + t.Fatalf("expected error message in result") + } +} + +func TestTokenRangeFirstToken(t *testing.T) { + start, end, token, ok := tokenRange(" /help now", tokenSelectorFirst) + if !ok { + t.Fatalf("expected token range to be found") + } + if token != "/help" { + t.Fatalf("expected first token to be /help, got %q", token) + } + if start < 0 || end <= start { + t.Fatalf("expected valid range, got %d-%d", start, end) + } +} + +func TestTokenRangeLastToken(t *testing.T) { + start, end, token, ok := tokenRange("one two three", tokenSelectorLast) + if !ok { + t.Fatalf("expected token range to be found") + } + if token != "three" { + t.Fatalf("expected last token to be three, got %q", token) + } + if start < 0 || end <= start { + t.Fatalf("expected valid range, got %d-%d", start, end) + } +} + +func TestCollectFileSuggestionMatches(t *testing.T) { + candidates := []string{"README.md", "docs/guide.md", "internal/app.go"} + matches := collectFileSuggestionMatches("read", candidates, 2) + if len(matches) == 0 { + t.Fatalf("expected matches for read") + } +} + +func TestShellArgsAndPowerShellUTF8(t *testing.T) { + args := shellArgs("bash", "echo hi") + if len(args) == 0 { + t.Fatalf("expected shell args to be returned") + } + utf8 := powershellUTF8Command("echo hi") + if utf8 == "" { + t.Fatalf("expected powershell utf8 command") + } +} + +func TestSanitizeAndDecodeWorkspaceOutput(t *testing.T) { + raw := []byte("hello\u0000world") + sanitized := sanitizeWorkspaceOutput(raw) + if sanitized == "" { + t.Fatalf("expected sanitized output") + } + decoded := decodeWorkspaceOutput(raw) + if decoded == "" { + t.Fatalf("expected decoded output") + } +} + +func TestViewSmallWindow(t *testing.T) { + app, _ := newTestApp(t) + app.width = 60 + app.height = 20 + + view := app.View() + if !strings.Contains(view, "Window too small") { + t.Fatalf("expected small window warning, got %q", view) + } +} + +func TestComputeLayoutStackedAndWide(t *testing.T) { + app, _ := newTestApp(t) + + app.width = 90 + app.height = 40 + layout := app.computeLayout() + if !layout.stacked { + t.Fatalf("expected stacked layout for narrow width") + } + if layout.rightWidth <= 0 || layout.sidebarWidth <= 0 { + t.Fatalf("expected positive layout widths, got %+v", layout) + } + + app.width = 140 + app.height = 40 + layout = app.computeLayout() + if layout.stacked { + t.Fatalf("expected non-stacked layout for wide width") + } + if layout.rightWidth <= 0 || layout.sidebarWidth <= 0 { + t.Fatalf("expected positive layout widths, got %+v", layout) + } +} + +func TestStatusBadgeVariants(t *testing.T) { + app, _ := newTestApp(t) + + errorBadge := app.statusBadge("Error occurred") + if strings.TrimSpace(errorBadge) == "" { + t.Fatalf("expected error badge to render") + } + + cancelBadge := app.statusBadge("Canceled") + if strings.TrimSpace(cancelBadge) == "" { + t.Fatalf("expected cancel badge to render") + } + + app.state.IsAgentRunning = true + runningBadge := app.statusBadge("Running") + if strings.TrimSpace(runningBadge) == "" { + t.Fatalf("expected running badge to render") + } + + app.state.IsAgentRunning = false + okBadge := app.statusBadge("Ready") + if strings.TrimSpace(okBadge) == "" { + t.Fatalf("expected success badge to render") + } +} + +func TestHelpHeightAndRenderHelp(t *testing.T) { + app, _ := newTestApp(t) + app.width = 120 + + app.state.ShowHelp = false + helpHeight := app.helpHeight(80) + if helpHeight <= 0 { + t.Fatalf("expected help height to be positive") + } + rendered := app.renderHelp(80) + if strings.TrimSpace(rendered) == "" { + t.Fatalf("expected renderHelp output") + } + + app.state.ShowHelp = true + helpHeight = app.helpHeight(80) + if helpHeight <= 0 { + t.Fatalf("expected help height to be positive when help is shown") + } +} + +func TestNewWithBootstrapSuccess(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Workdir = t.TempDir() + if len(cfg.Providers) > 0 { + cfg.SelectedProvider = cfg.Providers[0].Name + cfg.CurrentModel = cfg.Providers[0].Model + } + + manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + var providers []config.ProviderCatalogItem + var models []config.ModelDescriptor + if len(cfg.Providers) > 0 { + provider := cfg.Providers[0] + providers = []config.ProviderCatalogItem{ + { + ID: provider.Name, + Name: provider.Name, + Description: "test provider", + Models: []config.ModelDescriptor{ + {ID: provider.Model, Name: provider.Model}, + }, + }, + } + models = []config.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + } + + runtime := newStubRuntime() + app, err := NewWithBootstrap(tuibootstrap.Options{ + Config: cfg, + ConfigManager: manager, + Runtime: runtime, + ProviderService: stubProviderService{providers: providers, models: models}, + }) + if err != nil { + t.Fatalf("NewWithBootstrap() error = %v", err) + } + + cmd := app.Init() + if cmd == nil { + t.Fatalf("expected Init() to return command") + } +} + +func TestNewWithBootstrapMissingDependencies(t *testing.T) { + cfg := config.DefaultConfig() + + manager := config.NewManager(config.NewLoader(t.TempDir(), cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + if _, err := NewWithBootstrap(tuibootstrap.Options{ + Config: cfg, + ConfigManager: manager, + Runtime: nil, + ProviderService: stubProviderService{}, + }); err == nil { + t.Fatalf("expected error for nil runtime") + } + + if _, err := NewWithBootstrap(tuibootstrap.Options{ + Config: cfg, + ConfigManager: nil, + Runtime: newStubRuntime(), + ProviderService: stubProviderService{}, + }); err == nil { + t.Fatalf("expected error for nil config manager") + } +} + +func TestNewUsesBootstrap(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Workdir = t.TempDir() + if len(cfg.Providers) > 0 { + cfg.SelectedProvider = cfg.Providers[0].Name + cfg.CurrentModel = cfg.Providers[0].Model + } + + manager := config.NewManager(config.NewLoader(cfg.Workdir, cfg)) + if _, err := manager.Load(context.Background()); err != nil { + t.Fatalf("Load() error = %v", err) + } + + var providers []config.ProviderCatalogItem + var models []config.ModelDescriptor + if len(cfg.Providers) > 0 { + provider := cfg.Providers[0] + providers = []config.ProviderCatalogItem{ + { + ID: provider.Name, + Name: provider.Name, + Description: "test provider", + Models: []config.ModelDescriptor{ + {ID: provider.Model, Name: provider.Model}, + }, + }, + } + models = []config.ModelDescriptor{{ID: provider.Model, Name: provider.Model}} + } + + app, err := New(cfg, manager, newStubRuntime(), stubProviderService{providers: providers, models: models}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if app.state.CurrentProvider == "" { + t.Fatalf("expected CurrentProvider to be set") + } +} + +func TestRuntimeEventUserMessageHandler(t *testing.T) { + app, _ := newTestApp(t) + event := agentruntime.RuntimeEvent{RunID: "run-1"} + handled := runtimeEventUserMessageHandler(&app, event) + if handled { + t.Fatalf("expected false") + } + if app.state.ActiveRunID != "run-1" { + t.Fatalf("expected run id to be set") + } + if app.state.StatusText != statusThinking { + t.Fatalf("expected thinking status") + } +} + +func TestRuntimeEventRunContextHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := tuiservices.RuntimeRunContextPayload{ + Provider: "p1", + Model: "m1", + Workdir: "/tmp", + } + event := agentruntime.RuntimeEvent{RunID: "run-2", SessionID: "s1", Payload: payload} + handled := runtimeEventRunContextHandler(&app, event) + if handled { + t.Fatalf("expected false") + } + if app.state.CurrentProvider != "p1" || app.state.CurrentModel != "m1" { + t.Fatalf("expected provider/model to update") + } +} + +func TestRuntimeEventToolStatusHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := tuiservices.RuntimeToolStatusPayload{ToolCallID: "tool-1", ToolName: "bash", Status: string(tuistate.ToolLifecyclePlanned)} + handled := runtimeEventToolStatusHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if handled { + t.Fatalf("expected false") + } + if app.state.CurrentTool != "bash" { + t.Fatalf("expected current tool to be set") + } + payload.Status = string(tuistate.ToolLifecycleSucceeded) + _ = runtimeEventToolStatusHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if app.state.CurrentTool != "" { + t.Fatalf("expected current tool to be cleared") + } +} + +func TestRuntimeEventUsageHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := tuiservices.RuntimeUsagePayload{Run: tuiservices.RuntimeUsageSnapshot{InputTokens: 1, OutputTokens: 2, TotalTokens: 3}} + handled := runtimeEventUsageHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if handled { + t.Fatalf("expected false") + } + if app.state.TokenUsage.RunTotalTokens != 3 { + t.Fatalf("expected token usage to update") + } +} + +func TestRuntimeEventToolCallThinkingHandler(t *testing.T) { + app, _ := newTestApp(t) + handled := runtimeEventToolCallThinkingHandler(&app, agentruntime.RuntimeEvent{Payload: "bash"}) + if handled { + t.Fatalf("expected false") + } + if app.state.CurrentTool != "bash" { + t.Fatalf("expected current tool to be set") + } +} + +func TestRuntimeEventToolStartHandler(t *testing.T) { + app, _ := newTestApp(t) + call := providertypes.ToolCall{Name: "bash"} + handled := runtimeEventToolStartHandler(&app, agentruntime.RuntimeEvent{Payload: call}) + if handled { + t.Fatalf("expected false") + } + if app.state.StatusText != statusRunningTool { + t.Fatalf("expected running tool status") + } +} + +func TestRuntimeEventToolChunkHandler(t *testing.T) { + app, _ := newTestApp(t) + _ = runtimeEventToolChunkHandler(&app, agentruntime.RuntimeEvent{Payload: "chunk"}) + if app.state.StatusText != statusRunningTool { + t.Fatalf("expected running tool status") + } +} + +func TestRuntimeEventAgentChunkHandler(t *testing.T) { + app, _ := newTestApp(t) + handled := runtimeEventAgentChunkHandler(&app, agentruntime.RuntimeEvent{Payload: "hello"}) + if !handled { + t.Fatalf("expected true") + } + if len(app.activeMessages) == 0 { + t.Fatalf("expected message appended") + } +} + +func TestRuntimeEventRunCanceledHandler(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActiveRunID = "run-3" + runtimeEventRunCanceledHandler(&app, agentruntime.RuntimeEvent{}) + if app.state.StatusText != statusCanceled { + t.Fatalf("expected canceled status") + } + if app.state.ActiveRunID != "" { + t.Fatalf("expected run id cleared") + } +} + +func TestRuntimeEventErrorHandler(t *testing.T) { + app, _ := newTestApp(t) + runtimeEventErrorHandler(&app, agentruntime.RuntimeEvent{Payload: "boom"}) + if app.state.StatusText != "boom" { + t.Fatalf("expected status to be set to error") + } +} + +func TestRuntimeEventProviderRetryHandler(t *testing.T) { + app, _ := newTestApp(t) + runtimeEventProviderRetryHandler(&app, agentruntime.RuntimeEvent{Payload: "retry"}) + if app.state.StatusText != statusThinking { + t.Fatalf("expected thinking status") + } +} + +func TestRuntimeEventCompactDoneHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := agentruntime.CompactDonePayload{TriggerMode: "auto", SavedRatio: 0.5, BeforeChars: 10, AfterChars: 5, TranscriptPath: "path"} + handled := runtimeEventCompactDoneHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if !handled { + t.Fatalf("expected true") + } + if !strings.Contains(app.state.StatusText, "Compact(") { + t.Fatalf("expected compact status") + } +} + +func TestRuntimeEventCompactErrorHandler(t *testing.T) { + app, _ := newTestApp(t) + payload := agentruntime.CompactErrorPayload{TriggerMode: "auto", Message: "fail"} + handled := runtimeEventCompactErrorHandler(&app, agentruntime.RuntimeEvent{Payload: payload}) + if !handled { + t.Fatalf("expected true") + } + if app.state.ExecutionError == "" { + t.Fatalf("expected error message") + } +} + +func TestAppendAssistantAndInlineMessage(t *testing.T) { + app, _ := newTestApp(t) + app.appendAssistantChunk("hi") + app.appendAssistantChunk(" there") + if len(app.activeMessages) == 0 || !strings.Contains(app.activeMessages[len(app.activeMessages)-1].Content, "there") { + t.Fatalf("expected assistant chunk to append") + } + app.appendInlineMessage(roleSystem, " note ") + if len(app.activeMessages) < 2 { + t.Fatalf("expected inline message appended") + } +} + +func TestShouldHandleTabAsInput(t *testing.T) { + app, _ := newTestApp(t) + app.focus = panelInput + app.state.ActivePicker = pickerNone + app.input.SetValue("/he") + if !app.shouldHandleTabAsInput(tea.KeyMsg{Type: tea.KeyTab}) { + t.Fatalf("expected tab to be handled as input") + } + app.input.SetValue("") + if app.shouldHandleTabAsInput(tea.KeyMsg{Type: tea.KeyTab}) { + t.Fatalf("expected tab to be ignored for empty input") + } +} + +func TestFocusNextPrev(t *testing.T) { + app, _ := newTestApp(t) + app.focus = panelSessions + app.focusNext() + if app.focus == panelSessions { + t.Fatalf("expected focus to move") + } + app.focusPrev() +} + +func TestHandleViewportKeys(t *testing.T) { + app, _ := newTestApp(t) + app.transcript.SetContent("line1\nline2\nline3") + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyDown}) + app.handleViewportKeys(&app.transcript, tea.KeyMsg{Type: tea.KeyUp}) +} + +func TestUpdateEnterHelpOpensHelpPicker(t *testing.T) { + app, _ := newTestApp(t) + app.input.SetValue("/help") + app.state.InputText = "/help" + + model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter}) + if model == nil { + t.Fatalf("expected non-nil model") + } + app = model.(App) + if cmd != nil { + t.Fatalf("expected no async cmd when opening help picker") + } + if app.state.ActivePicker != pickerHelp { + t.Fatalf("expected help picker to be active") + } + if app.state.StatusText != statusChooseHelp { + t.Fatalf("expected status %q, got %q", statusChooseHelp, app.state.StatusText) + } + if len(app.helpPicker.Items()) != len(builtinSlashCommands) { + t.Fatalf("expected %d help options, got %d", len(builtinSlashCommands), len(app.helpPicker.Items())) + } +} + +func TestUpdatePickerHelpSelectionOpensModelPicker(t *testing.T) { + app, _ := newTestApp(t) + app.refreshHelpPicker() + app.openHelpPicker() + selectPickerItemByID(&app.helpPicker, slashCommandModelPick) + + model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + if model == nil { + t.Fatalf("expected model") + } + app = model.(App) + if cmd != nil { + _ = cmd() + } + if app.state.ActivePicker != pickerModel { + t.Fatalf("expected model picker to open from help selection") + } +} + +func TestUpdatePickerHelpSelectionRunsSlashCommand(t *testing.T) { + app, _ := newTestApp(t) + app.refreshHelpPicker() + app.openHelpPicker() + selectPickerItemByID(&app.helpPicker, slashCommandStatus) + + model, cmd := app.updatePicker(tea.KeyMsg{Type: tea.KeyEnter}) + if model == nil { + t.Fatalf("expected model") + } + app = model.(App) + if app.state.ActivePicker != pickerNone { + t.Fatalf("expected help picker to close after selecting /status") + } + if cmd == nil { + t.Fatalf("expected local slash command cmd") + } + msg := cmd() + result, ok := msg.(localCommandResultMsg) + if !ok { + t.Fatalf("expected localCommandResultMsg, got %T", msg) + } + if !strings.Contains(result.Notice, "Status:") { + t.Fatalf("expected status output in slash result, got %q", result.Notice) + } +} + +func TestRunSlashCommandSelectionModelReturnsRefreshCmd(t *testing.T) { + app, _ := newTestApp(t) + app.modelRefreshID = "" + + cmd := app.runSlashCommandSelection(slashCommandModelPick) + if app.state.ActivePicker != pickerModel { + t.Fatalf("expected model picker to open") + } + if cmd == nil { + t.Fatalf("expected model refresh cmd") + } + msg := cmd() + if _, ok := msg.(modelCatalogRefreshMsg); !ok { + t.Fatalf("expected modelCatalogRefreshMsg, got %T", msg) + } +} + +func TestRunSlashCommandSelectionProviderRefreshError(t *testing.T) { + app, _ := newTestApp(t) + app.providerSvc = errorProviderService{err: errors.New("provider refresh failed")} + + cmd := app.runSlashCommandSelection(slashCommandProvider) + if cmd != nil { + t.Fatalf("expected nil cmd when provider refresh fails") + } + if !strings.Contains(app.state.StatusText, "provider refresh failed") { + t.Fatalf("expected provider refresh error status, got %q", app.state.StatusText) + } +} + +func TestRunSlashCommandSelectionModelRefreshError(t *testing.T) { + app, _ := newTestApp(t) + app.providerSvc = errorProviderService{err: errors.New("model refresh failed")} + + cmd := app.runSlashCommandSelection(slashCommandModelPick) + if cmd != nil { + t.Fatalf("expected nil cmd when model refresh fails") + } + if !strings.Contains(app.state.StatusText, "model refresh failed") { + t.Fatalf("expected model refresh error status, got %q", app.state.StatusText) + } +} + +func TestRunSlashCommandSelectionWorkspaceAndLocal(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActiveSessionID = "" + app.state.CurrentWorkdir = t.TempDir() + + workspaceCmd := app.runSlashCommandSelection("/cwd") + if workspaceCmd == nil { + t.Fatalf("expected workspace slash cmd") + } + workspaceMsg := workspaceCmd() + workspaceResult, ok := workspaceMsg.(sessionWorkdirResultMsg) + if !ok { + t.Fatalf("expected sessionWorkdirResultMsg, got %T", workspaceMsg) + } + if workspaceResult.Err != nil { + t.Fatalf("expected no workspace error, got %v", workspaceResult.Err) + } + + localCmd := app.runSlashCommandSelection(slashCommandStatus) + if localCmd == nil { + t.Fatalf("expected local slash cmd") + } + localMsg := localCmd() + localResult, ok := localMsg.(localCommandResultMsg) + if !ok { + t.Fatalf("expected localCommandResultMsg, got %T", localMsg) + } + if !strings.Contains(localResult.Notice, "Status:") { + t.Fatalf("expected status output in local command result") + } +} + +func TestHandleImmediateSlashCommandCompactBranches(t *testing.T) { + app, runtime := newTestApp(t) + app.state.ActiveSessionID = "session-1" + + handled, cmd := app.handleImmediateSlashCommand(slashCommandCompact + " now") + if !handled || cmd != nil { + t.Fatalf("expected compact with args to be handled without cmd") + } + if !strings.Contains(app.state.StatusText, "usage:") { + t.Fatalf("expected usage error for compact with args") + } + + app.state.ExecutionError = "" + app.state.IsCompacting = true + handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) + if !handled || cmd != nil { + t.Fatalf("expected compact busy branch to return handled with nil cmd") + } + if !strings.Contains(app.state.StatusText, "already running") { + t.Fatalf("expected busy message") + } + + app.state.IsCompacting = false + app.state.IsAgentRunning = false + app.state.StatusText = "" + handled, cmd = app.handleImmediateSlashCommand(slashCommandCompact) + if !handled || cmd == nil { + t.Fatalf("expected compact success branch to return cmd") + } + msg := cmd() + if _, ok := msg.(compactFinishedMsg); !ok { + t.Fatalf("expected compactFinishedMsg, got %T", msg) + } + if len(runtime.resolveCalls) != 0 { + t.Fatalf("compact should not resolve permissions") + } +} + +func TestHandleImmediateSlashCommandDefault(t *testing.T) { + app, _ := newTestApp(t) + handled, cmd := app.handleImmediateSlashCommand("/unknown") + if handled || cmd != nil { + t.Fatalf("expected unknown slash command to be ignored") + } +} + +func TestFormatPermissionPromptToolOnly(t *testing.T) { + lines := formatPermissionPromptLines(permissionPromptState{ + Request: agentruntime.PermissionRequestPayload{ToolName: "bash"}, + }) + if len(lines) == 0 || !strings.Contains(lines[0], "Permission request: bash") { + t.Fatalf("expected tool-only prompt header, got %#v", lines) + } +} + +func TestStartDraftSessionResetsRunState(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActiveSessionID = "session-1" + app.state.ActiveSessionTitle = "Session 1" + app.state.ActiveRunID = "run-1" + app.state.CurrentTool = "bash" + app.state.ToolStates = []tuistate.ToolState{{ToolCallID: "tool-1", ToolName: "bash"}} + app.state.RunContext = tuistate.ContextWindowState{Provider: "openai"} + app.state.TokenUsage = tuistate.TokenUsageState{RunTotalTokens: 123} + app.activities = []tuistate.ActivityEntry{{Title: "activity"}} + app.state.CurrentWorkdir = t.TempDir() + + app.startDraftSession() + + if app.state.ActiveRunID != "" { + t.Fatalf("expected run id to be reset") + } + if app.state.CurrentTool != "" { + t.Fatalf("expected current tool to be reset") + } + if len(app.state.ToolStates) != 0 { + t.Fatalf("expected tool states to be reset") + } + if app.state.ActiveSessionID != "" || app.state.ActiveSessionTitle != draftSessionTitle { + t.Fatalf("expected draft session state") + } + if len(app.activities) != 0 { + t.Fatalf("expected activities to be cleared") + } +} diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 10a47c10..7247f218 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -129,6 +129,15 @@ func (a App) renderSidebar(width int, height int) string { return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, panel) } +// waterfallMetrics 统一计算瀑布区各组件高度,确保渲染、布局与命中区域使用同一组尺寸。 +func (a App) waterfallMetrics(width int, height int) (int, int, int, int) { + activityHeight := a.activityPreviewHeight() + menuHeight := a.commandMenuHeight(width) + promptHeight := lipgloss.Height(a.renderPrompt(width)) + transcriptHeight := max(6, height-activityHeight-menuHeight-promptHeight) + return transcriptHeight, activityHeight, menuHeight, promptHeight +} + func (a App) renderWaterfall(width int, height int) string { if a.state.ActivePicker != pickerNone { return lipgloss.Place( @@ -140,7 +149,9 @@ func (a App) renderWaterfall(width int, height int) string { ) } - transcript := a.styles.streamContent.Width(width).Height(a.transcript.Height).Render(a.transcript.View()) + transcriptHeight, _, _, _ := a.waterfallMetrics(width, height) + + transcript := a.styles.streamContent.Width(width).Height(transcriptHeight).Render(a.transcript.View()) parts := []string{transcript} if activity := a.renderActivityPreview(width); activity != "" { @@ -151,7 +162,8 @@ func (a App) renderWaterfall(width int, height int) string { } parts = append(parts, a.renderPrompt(width)) - return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, lipgloss.JoinVertical(lipgloss.Left, parts...)) + content := lipgloss.JoinVertical(lipgloss.Left, parts...) + return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, content) } func (a App) renderPicker(width int, height int) string { @@ -169,6 +181,11 @@ func (a App) renderPicker(width int, height int) string { subtitle = filePickerSubtitle body = a.fileBrowser.View() } + if a.state.ActivePicker == pickerHelp { + title = helpPickerTitle + subtitle = helpPickerSubtitle + body = a.helpPicker.View() + } content := lipgloss.JoinVertical( lipgloss.Left, a.styles.panelTitle.Render(title), @@ -190,8 +207,7 @@ func (a App) renderPrompt(width int) string { // Account for frame and padding when sizing the composer container. boxWidth := a.composerBoxWidth(width) - - return box.Width(boxWidth).Render(a.input.View()) + return box.Width(boxWidth).Render(a.renderPermissionPrompt()) } func (a App) renderSidebarHeader(width int) string { diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go new file mode 100644 index 00000000..cb638676 --- /dev/null +++ b/internal/tui/core/app/view_test.go @@ -0,0 +1,76 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/bubbles/list" + + tuistate "neo-code/internal/tui/state" +) + +func TestRenderPickerHelpMode(t *testing.T) { + app, _ := newTestApp(t) + app.refreshHelpPicker() + app.state.ActivePicker = pickerHelp + + view := app.renderPicker(48, 14) + if !strings.Contains(view, helpPickerTitle) { + t.Fatalf("expected help picker title in view") + } + if !strings.Contains(view, helpPickerSubtitle) { + t.Fatalf("expected help picker subtitle in view") + } +} + +func TestRenderWaterfallUsesDynamicTranscriptHeight(t *testing.T) { + app, _ := newTestApp(t) + app.state.ActivePicker = pickerNone + app.state.InputText = "test" + app.input.SetValue("test") + app.transcript.SetContent("line1\nline2") + + view := app.renderWaterfall(80, 24) + if strings.TrimSpace(view) == "" { + t.Fatalf("expected non-empty waterfall view") + } +} + +func TestApplyComponentLayoutKeepsTranscriptHeightInSyncWithWaterfall(t *testing.T) { + app, _ := newTestApp(t) + app.width = 100 + app.height = 24 + app.focus = panelInput + app.activities = []tuistate.ActivityEntry{{Kind: "tool", Title: "running", Detail: "tool call"}} + app.commandMenu.SetItems([]list.Item{ + commandMenuItem{title: "/help", description: "show help"}, + commandMenuItem{title: "/model", description: "switch model"}, + }) + app.commandMenuMeta = tuistate.CommandMenuMeta{Title: commandMenuTitle} + app.input.SetValue(strings.Repeat("line\n", 5)) + app.input.SetHeight(app.composerHeight()) + + app.applyComponentLayout(false) + + lay := app.computeLayout() + wantTranscriptHeight, activityHeight, menuHeight, _ := app.waterfallMetrics(app.transcript.Width, lay.rightHeight) + if app.transcript.Height != wantTranscriptHeight { + t.Fatalf("expected transcript height %d, got %d", wantTranscriptHeight, app.transcript.Height) + } + + _, transcriptY, _, transcriptHeight := app.transcriptBounds() + _, activityY, _, gotActivityHeight := app.activityBounds() + _, inputY, _, _ := app.inputBounds() + if transcriptHeight != wantTranscriptHeight { + t.Fatalf("expected transcript bounds height %d, got %d", wantTranscriptHeight, transcriptHeight) + } + if activityY != transcriptY+wantTranscriptHeight { + t.Fatalf("expected activity Y %d, got %d", transcriptY+wantTranscriptHeight, activityY) + } + if gotActivityHeight != activityHeight { + t.Fatalf("expected activity height %d, got %d", activityHeight, gotActivityHeight) + } + if inputY != transcriptY+wantTranscriptHeight+activityHeight+menuHeight { + t.Fatalf("expected input Y %d, got %d", transcriptY+wantTranscriptHeight+activityHeight+menuHeight, inputY) + } +} diff --git a/internal/tui/core/utils/view_helpers.go b/internal/tui/core/utils/view_helpers.go index 4c0d029a..58c0373c 100644 --- a/internal/tui/core/utils/view_helpers.go +++ b/internal/tui/core/utils/view_helpers.go @@ -15,6 +15,8 @@ func PickerLabelFromMode(mode tuistate.PickerMode) string { return "model" case tuistate.PickerFile: return "file" + case tuistate.PickerHelp: + return "help" default: return "none" } diff --git a/internal/tui/core/utils/view_helpers_test.go b/internal/tui/core/utils/view_helpers_test.go index 9a37e084..d700f1fc 100644 --- a/internal/tui/core/utils/view_helpers_test.go +++ b/internal/tui/core/utils/view_helpers_test.go @@ -14,6 +14,7 @@ func TestPickerLabelFromMode(t *testing.T) { {tuistate.PickerProvider, "provider"}, {tuistate.PickerModel, "model"}, {tuistate.PickerFile, "file"}, + {tuistate.PickerHelp, "help"}, {tuistate.PickerMode(999), "none"}, } diff --git a/internal/tui/docs/LAYERING.md b/internal/tui/docs/LAYERING.md deleted file mode 100644 index 4ff7b040..00000000 --- a/internal/tui/docs/LAYERING.md +++ /dev/null @@ -1,94 +0,0 @@ -# TUI 分层约束(Iteration 0) - -本文档用于约束 `internal/tui` 的分层职责与依赖方向,确保后续迭代按层收敛,不跨层扩散。 - -## 改造范围 - -- 本轮只处理 `internal/tui`。 -- 入口层 `cmd/neocode` 暂不处理。 - -## 分层定义 - -### L1 - Entry(暂缓) - -- 位置:`cmd/neocode/` -- 职责:参数解析、终端初始化、启动 Program。 -- 本轮状态:暂不纳入改造。 - -### L2 - Bootstrap - -- 位置:`internal/tui/bootstrap/` -- 职责:依赖注入(DI)与初始化编排。 -- 负责:工作区/配置初始化、服务装配、Offline/Mock 注入切换。 - -### L3 - App/Core - -- 位置:`internal/tui/core/` -- 职责:Bubble Tea 状态机中枢(ELM 单向数据流)。 -- 负责:消息路由、状态变更、布局调度。 - -### L4 - State - -- 位置:`internal/tui/state/` -- 职责:纯数据容器。 -- 约束:只放结构体和常量,不放方法与副作用。 - -### L5 - Component Adapter - -- 位置:`internal/tui/components/` -- 职责:原子渲染组件。 -- 输入:基础数据或 state。 -- 输出:渲染字符串。 - -### L6 - Services - -- 位置:`internal/tui/services/` -- 职责:对接 runtime/provider/本地系统能力。 -- 约束:统一返回 `tea.Cmd` 或异步产出 `tea.Msg`。 - -### L7 - Infrastructure - -- 位置:`internal/tui/infra/` -- 职责:底层 I/O 与系统能力。 -- 范围:shell 执行、文件扫描、终端 I/O、渲染器、剪贴板等。 - -## 依赖方向(允许) - -- `core` -> `state` -- `core` -> `components` -- `core` -> `services` -- `services` -> `infra` - -## 禁止项 - -- 禁止 `components` 直接访问 runtime/provider 或执行外部 I/O。 -- 禁止 `core` 直接调用底层系统能力(应经 `services`)。 -- 禁止 `state` 承载业务逻辑、网络调用或文件操作。 -- 禁止新增跨层直连(例如 `core` 直接依赖 `infra`)。 -- 禁止在本轮引入行为变更;Iteration 0 只做骨架与规则。 - -## Iteration 0 验收 - -- 目录骨架已创建:`bootstrap/core/state/components/services/infra` -- 分层约束文档已建立 -- `go test ./internal/tui/...` 通过 - -## Iteration 6 补充(Bootstrap 落地) - -- `internal/tui/bootstrap` 已提供 `Build` 装配入口,统一完成 `ConfigManager + Runtime + ProviderService` 注入。 -- 支持 `Mode`(`live/offline/mock`)与 `ServiceFactory` 扩展点,可在不修改 `core` 的情况下替换注入实现。 -- `internal/tui.New(...)` 保持兼容签名,对外作为薄封装;实际装配路径为 `New -> bootstrap.Build -> newApp`。 - -## Iteration 7 补充(Runtime Source 收敛) - -- Runtime 事件新增并接入 UI 桥接: - - `EventToolStatus` - - `EventRunContext` - - `EventUsage` -- Runtime 查询接口已落地: - - `GetRunSnapshot(runID)` - - `GetSessionContext(sessionID)` - - `GetSessionUsage(sessionID)` - - `GetRunUsage(runID)` -- `internal/tui/core/runtime_bridge.go` 统一处理 payload -> VM 映射与 Tool 状态去重合并(覆盖重复/乱序事件场景)。 -- TUI 在会话刷新时优先通过 runtime 查询回填 context/token 快照,避免由 UI 本地推导。 diff --git a/internal/tui/services/runtime_service.go b/internal/tui/services/runtime_service.go index c151bad6..d4982d4d 100644 --- a/internal/tui/services/runtime_service.go +++ b/internal/tui/services/runtime_service.go @@ -2,12 +2,15 @@ package services import ( "context" + "time" tea "github.com/charmbracelet/bubbletea" agentruntime "neo-code/internal/runtime" ) +const permissionResolveTimeout = 10 * time.Second + // Runner 定义执行 runtime run 所需最小能力。 type Runner interface { Run(ctx context.Context, input agentruntime.UserInput) error @@ -18,6 +21,11 @@ type Compactor interface { Compact(ctx context.Context, input agentruntime.CompactInput) (agentruntime.CompactResult, error) } +// PermissionResolver 定义权限审批提交所需最小能力。 +type PermissionResolver interface { + ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error +} + // ListenForRuntimeEventCmd 监听 runtime 事件通道,并将结果映射为 UI 消息。 func ListenForRuntimeEventCmd( sub <-chan agentruntime.RuntimeEvent, @@ -56,3 +64,18 @@ func RunCompactCmd( return doneMsg(err) } } + +// RunResolvePermissionCmd 提交权限审批决定,并将结果映射为 UI 消息。 +func RunResolvePermissionCmd( + runtime PermissionResolver, + input agentruntime.PermissionResolutionInput, + doneMsg func(agentruntime.PermissionResolutionInput, error) tea.Msg, +) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), permissionResolveTimeout) + defer cancel() + + err := runtime.ResolvePermission(ctx, input) + return doneMsg(input, err) + } +} diff --git a/internal/tui/services/services_test.go b/internal/tui/services/services_test.go index cde184c9..b5893084 100644 --- a/internal/tui/services/services_test.go +++ b/internal/tui/services/services_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "testing" + "time" tea "github.com/charmbracelet/bubbletea" @@ -33,6 +34,19 @@ func (s *stubCompactor) Compact(ctx context.Context, input agentruntime.CompactI return agentruntime.CompactResult{}, s.err } +type stubPermissionResolver struct { + lastInput agentruntime.PermissionResolutionInput + err error + deadline time.Time + hasDeadline bool +} + +func (s *stubPermissionResolver) ResolvePermission(ctx context.Context, input agentruntime.PermissionResolutionInput) error { + s.lastInput = input + s.deadline, s.hasDeadline = ctx.Deadline() + return s.err +} + type stubProvider struct { selection config.ProviderSelection models []config.ModelDescriptor @@ -101,6 +115,44 @@ func TestRunCompactCmd(t *testing.T) { } } +func TestRunResolvePermissionCmd(t *testing.T) { + resolver := &stubPermissionResolver{err: errors.New("permission failed")} + input := agentruntime.PermissionResolutionInput{ + RequestID: "perm-1", + Decision: agentruntime.PermissionResolutionAllowSession, + } + msg := RunResolvePermissionCmd( + resolver, + input, + func(in agentruntime.PermissionResolutionInput, err error) tea.Msg { + return struct { + Input agentruntime.PermissionResolutionInput + Err error + }{Input: in, Err: err} + }, + )() + + got, ok := msg.(struct { + Input agentruntime.PermissionResolutionInput + Err error + }) + if !ok { + t.Fatalf("expected wrapped permission result message, got %T %#v", msg, msg) + } + if got.Input.RequestID != "perm-1" || got.Input.Decision != agentruntime.PermissionResolutionAllowSession { + t.Fatalf("unexpected permission input forwarded: %+v", got.Input) + } + if got.Err == nil || got.Err.Error() != "permission failed" { + t.Fatalf("expected forwarded permission error, got %#v", got.Err) + } + if resolver.lastInput.RequestID != "perm-1" || resolver.lastInput.Decision != agentruntime.PermissionResolutionAllowSession { + t.Fatalf("unexpected resolver input: %+v", resolver.lastInput) + } + if !resolver.hasDeadline { + t.Fatalf("expected permission resolver context to carry a deadline") + } +} + func TestProviderCmds(t *testing.T) { svc := &stubProvider{ selection: config.ProviderSelection{ProviderID: "openai", ModelID: "gpt-5.4"}, diff --git a/internal/tui/state/messages.go b/internal/tui/state/messages.go index 9016281d..c3347f75 100644 --- a/internal/tui/state/messages.go +++ b/internal/tui/state/messages.go @@ -30,6 +30,13 @@ type CompactFinishedMsg struct { Err error } +// PermissionResolvedMsg 表示权限审批结果已回传。 +type PermissionResolvedMsg struct { + RequestID string + Decision string + Err error +} + // LocalCommandResultMsg 表示本地命令执行结果。 type LocalCommandResultMsg struct { Notice string @@ -51,3 +58,10 @@ type WorkspaceCommandResultMsg struct { Output string Err error } + +// PermissionResolutionFinishedMsg 表示一次权限审批提交完成结果。 +type PermissionResolutionFinishedMsg struct { + RequestID string + Decision agentruntime.PermissionResolutionDecision + Err error +} diff --git a/internal/tui/state/state_test.go b/internal/tui/state/state_test.go index 73e34cda..599ddfe1 100644 --- a/internal/tui/state/state_test.go +++ b/internal/tui/state/state_test.go @@ -6,8 +6,15 @@ func TestPanelAndPickerConstants(t *testing.T) { if PanelSessions != 0 || PanelTranscript != 1 || PanelActivity != 2 || PanelInput != 3 { t.Fatalf("unexpected panel constants: %d %d %d %d", PanelSessions, PanelTranscript, PanelActivity, PanelInput) } - if PickerNone != 0 || PickerProvider != 1 || PickerModel != 2 || PickerFile != 3 { - t.Fatalf("unexpected picker constants: %d %d %d %d", PickerNone, PickerProvider, PickerModel, PickerFile) + if PickerNone != 0 || PickerProvider != 1 || PickerModel != 2 || PickerFile != 3 || PickerHelp != 4 { + t.Fatalf( + "unexpected picker constants: %d %d %d %d %d", + PickerNone, + PickerProvider, + PickerModel, + PickerFile, + PickerHelp, + ) } } diff --git a/internal/tui/state/ui_state.go b/internal/tui/state/ui_state.go index 9fa071a3..706b99dc 100644 --- a/internal/tui/state/ui_state.go +++ b/internal/tui/state/ui_state.go @@ -20,6 +20,7 @@ const ( PickerProvider PickerModel PickerFile + PickerHelp ) // UIState 保存顶层界面状态快照,仅作为数据容器使用。