Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,8 @@ go run ./cmd/neocode --workdir /path/to/workspace
说明:

- `--workdir` 只影响当前进程,不会写回 `config.yaml`
- 当前工作区会同时用于工具执行根目录与 session 存储分桶
- 当前工作区会同时影响工具默认执行根目录与 session 作用域;若目录位于 Git 仓库内,session 作用域优先使用仓库根目录
- 运行中的 `/cwd` 只影响当前进程:同工作区内保留当前会话,跨工作区时切换到目标工作区的 session 分桶并重置为 Draft
- session 历史现在按工作区隔离存储,不同工作区默认互不可见

[![Contributors](https://hub-io-mcells-projects.vercel.app/r/1024XEngineer/neo-code)](https://github.com/1024XEngineer/neo-code/graphs/contributors)
55 changes: 55 additions & 0 deletions cmd/neocode/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package main

import (
"os"
"os/exec"
"strings"
"testing"
)

func TestMainHelpAndErrorExitPaths(t *testing.T) {
t.Run("help exits successfully", func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcessMain", "--", "--help")
cmd.Env = append(os.Environ(), "GO_WANT_MAIN_HELPER=1")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("expected help path to exit successfully, got %v (%s)", err, string(output))
}
if !strings.Contains(string(output), "Usage:") {
t.Fatalf("expected help output, got %q", string(output))
}
})

t.Run("invalid flag exits with error", func(t *testing.T) {
cmd := exec.Command(os.Args[0], "-test.run=TestHelperProcessMain", "--", "--bad-flag")
cmd.Env = append(os.Environ(), "GO_WANT_MAIN_HELPER=1")
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("expected invalid flag path to exit with error")
}
if !strings.Contains(string(output), "unknown flag") {
t.Fatalf("expected invalid flag output, got %q", string(output))
}
})
}

func TestHelperProcessMain(t *testing.T) {
if os.Getenv("GO_WANT_MAIN_HELPER") != "1" {
return
}

args := []string{"neocode"}
for _, arg := range os.Args {
if arg == "--" {
break
}
}
for i, arg := range os.Args {
if arg == "--" {
args = append(args, os.Args[i+1:]...)
break
}
}
os.Args = args
main()
}
3 changes: 2 additions & 1 deletion docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,5 +342,6 @@ go run ./cmd/neocode --workdir /path/to/workspace
补充说明:

- `--workdir` 只影响本次启动,不会持久化到 `config.yaml`
- 运行时工具根目录与 session 存储分桶都会使用该工作区
- 运行时工具默认根目录与初始 session 作用域都会从该目录出发;若目录位于 Git 仓库内,则 session 作用域优先使用仓库根目录
- `/cwd` 只影响当前进程中的工作区上下文,不会回写配置;同工作区内保留当前会话,跨工作区时会切换 session 分桶并重置为 Draft
- session 现按工作区隔离存储,不同工作区的历史会话默认互不可见
5 changes: 3 additions & 2 deletions docs/session-persistence-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ NeoCode 在 MVP 阶段使用 JSON 文件持久化 Session,以保持本地优
## 工作区隔离

- session 现按工作区隔离存储,目录规则为 `~/.neocode/projects/<workspace-hash>/sessions/`
- 工作区哈希基于启动时确定的工作区根目录生成,而不是基于 `session.Workdir`
- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录,可被 `/cwd` 修改
- 工作区哈希基于当前 runtime 持有的 `workspaceRoot` 生成;`workspaceRoot` 优先取目标目录所在 Git 仓库根目录,非 Git 目录则退回目标目录本身
- `session.Workdir` 仍表示该会话当前实际执行命令时使用的目录;同工作区内 `/cwd` 只更新该字段
- 当 `/cwd` 跨到新的 `workspaceRoot` 时,runtime 会切换到新的 session 分桶并重置为 Draft,不迁移旧会话历史
- 旧的全局 `~/.neocode/sessions/` 开发期数据不迁移、不回读
2 changes: 1 addition & 1 deletion internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
return RuntimeBundle{}, err
}

sessionStore := agentsession.NewStore(loader.BaseDir(), cfg.Workdir)
sessionStore := agentsession.NewScopedStoreRouter(loader.BaseDir(), cfg.Workdir)
runtimeSvc := agentruntime.NewWithFactory(
manager,
toolManager,
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ func TestNewRootCommandAllowsEmptyWorkdir(t *testing.T) {
}

cmd := NewRootCommand()
cmd.SetArgs([]string{})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatalf("ExecuteContext() error = %v", err)
}
Expand All @@ -61,6 +62,7 @@ func TestNewRootCommandReturnsLauncherError(t *testing.T) {
}

cmd := NewRootCommand()
cmd.SetArgs([]string{})
err := cmd.ExecuteContext(context.Background())
if !errors.Is(err, expected) {
t.Fatalf("expected launcher error %v, got %v", expected, err)
Expand Down
16 changes: 8 additions & 8 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1231,10 +1231,10 @@ func TestDescriptorFromRawModel(t *testing.T) {
t.Parallel()

tests := []struct {
name string
raw map[string]any
want ModelDescriptor
wantOK bool
name string
raw map[string]any
want ModelDescriptor
wantOK bool
}{
{
name: "empty map returns false",
Expand All @@ -1252,10 +1252,10 @@ func TestDescriptorFromRawModel(t *testing.T) {
{
name: "full descriptor",
raw: map[string]any{
"id": "gpt-4.1",
"display_name": "GPT-4.1",
"description": "desc",
"context_window": 128000,
"id": "gpt-4.1",
"display_name": "GPT-4.1",
"description": "desc",
"context_window": 128000,
"max_output_tokens": 16384,
},
want: ModelDescriptor{
Expand Down
2 changes: 1 addition & 1 deletion internal/config/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const (
DefaultWebFetchMaxResponseBytes int64 = 256 * 1024
DefaultCompactManualKeepRecentMessages = 10
DefaultCompactMaxSummaryChars = 1200
DefaultAutoCompactInputTokenThreshold = 100000
DefaultAutoCompactInputTokenThreshold = 100000
)

const (
Expand Down
11 changes: 8 additions & 3 deletions internal/provider/openai/openai_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -547,11 +547,16 @@ func TestConsumeStream_ContextCancellation(t *testing.T) {

sseData := `data: {"id":"a","choices":[{"delta":{"content":"should not emit"}}]}

`
`
events := make(chan providertypes.StreamEvent, 1)
err = p.consumeStream(ctx, strings.NewReader(sseData), events)
if err != nil && !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled or nil, got: %v", err)
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got: %v", err)
}
select {
case evt := <-events:
t.Fatalf("expected no events after cancellation, got: %#v", evt)
default:
}
}

Expand Down
9 changes: 9 additions & 0 deletions internal/provider/openai/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,15 @@ func (p *Provider) consumeStream(
}

for {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
line, err := reader.ReadLine()

if err != nil && !errors.Is(err, io.EOF) {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
// 非 EOF 的读取错误:先刷新缓冲的 data 行,再包装为流中断,
// 避免中断前最后一段数据丢失。
if flushErr := flushPendingData(); flushErr != nil {
Expand Down Expand Up @@ -116,6 +122,9 @@ func (p *Provider) consumeStream(
}

if errors.Is(err, io.EOF) {
if ctxErr := ctx.Err(); ctxErr != nil {
return ctxErr
}
// [DEBUG] 流 EOF 时打印关键状态,用于诊断截断原因
log.Printf("[DEBUG-STREAM] EOF reached: done=%v, finishReason=%q, totalRead=%d, toolCallCount=%d",
done, finishReason, reader.totalRead, len(toolCalls))
Expand Down
13 changes: 10 additions & 3 deletions internal/runtime/compact.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,15 @@ func (s *Service) Compact(ctx context.Context, input CompactInput) (CompactResul

s.operationMu.Lock()
defer s.operationMu.Unlock()
s.setOperationState("compact")
defer s.clearOperationState()

cfg := s.configManager.Get()
session, err := s.sessionStore.Load(ctx, input.SessionID)
store := s.currentSessionStore()
if store == nil {
return CompactResult{}, errors.New("runtime: session store is nil")
}
session, err := store.Load(ctx, input.SessionID)
if err != nil {
return CompactResult{}, err
}
Expand Down Expand Up @@ -107,10 +113,11 @@ func (s *Service) runCompactForSession(
originalMessages := append([]providertypes.Message(nil), session.Messages...)
s.emit(ctx, EventCompactStart, runID, session.ID, string(contextcompact.ModeManual))

workspaceRoot, workspaceWorkdir := s.currentWorkspaceState()
result, err := runner.Run(ctx, contextcompact.Input{
Mode: contextcompact.ModeManual,
SessionID: session.ID,
Workdir: cfg.Workdir,
Workdir: effectiveSessionWorkdir(session.Workdir, effectiveWorkspaceBase(workspaceWorkdir, workspaceRoot)),
Messages: session.Messages,
Config: cfg.Context.Compact,
})
Expand All @@ -128,7 +135,7 @@ func (s *Service) runCompactForSession(
if result.Applied {
session.Messages = append([]providertypes.Message(nil), result.Messages...)
session.UpdatedAt = time.Now()
if err := s.sessionStore.Save(ctx, &session); err != nil {
if err := s.currentSessionStore().Save(ctx, &session); err != nil {
s.emit(ctx, EventCompactError, runID, session.ID, CompactErrorPayload{
TriggerMode: string(contextcompact.ModeManual),
Message: err.Error(),
Expand Down
Loading
Loading