Skip to content

Commit 7bf80f3

Browse files
authored
Merge pull request #374 from pionxe/main
feat(tui): [EPIC-INT-01B] 引入 RemoteRuntimeAdapter,实现 TUI 客户端向 Gateway 平滑下沉
2 parents 30effcc + f54771f commit 7bf80f3

25 files changed

Lines changed: 5110 additions & 34 deletions

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,19 @@ $env:QINIU_API_KEY = "your_key_here"
100100
go run ./cmd/neocode --workdir /path/to/workspace
101101
```
102102

103+
运行模式切换(默认 `local`):
104+
105+
```bash
106+
go run ./cmd/neocode --runtime-mode local
107+
go run ./cmd/neocode --runtime-mode gateway
108+
```
109+
110+
说明:
111+
112+
- `--runtime-mode` 仅影响当前进程,不会回写 `config.yaml`
113+
- `gateway` 模式会通过本地 Gateway(优先 IPC)转发 runtime 请求与事件流
114+
- 若 Gateway 不可达或握手失败会直接报错退出(Fail Fast),不会自动回退到 `local`
115+
103116
### 4) 首次使用与常用命令
104117
- `/help`:查看命令帮助
105118
- `/provider`:打开 provider 选择器
@@ -127,6 +140,7 @@ go run ./cmd/neocode --workdir /path/to/workspace
127140

128141
- API Key 通过环境变量注入,不写入 `config.yaml`
129142
- `--workdir` 只影响当前运行,不会回写到配置文件
143+
- `--runtime-mode` 默认 `local`,用于灰度切换到 `gateway` 模式
130144

131145
详细配置请参考:[docs/guides/configuration.md](docs/guides/configuration.md)
132146

docs/guides/configuration.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,19 +240,24 @@ $env:GEMINI_API_KEY = "AI..."
240240

241241
不要把这两层职责混在一起理解。
242242

243-
## CLI Workdir 覆盖
243+
## CLI 运行参数覆盖
244244

245-
工作目录不写入 `config.yaml`,只通过启动参数覆盖:
245+
工作目录与运行模式都不写入 `config.yaml`,只通过启动参数覆盖:
246246

247247
```bash
248248
go run ./cmd/neocode --workdir /path/to/workspace
249+
go run ./cmd/neocode --runtime-mode local
250+
go run ./cmd/neocode --runtime-mode gateway
249251
```
250252

251253
说明:
252254

253255
- `--workdir` 只影响本次进程
254256
- 不会回写到 `config.yaml`
255257
- 工具根目录与 session 隔离都会使用该工作区
258+
- `--runtime-mode` 默认为 `local`,可切换为 `gateway`
259+
- `gateway` 模式会通过本地 Gateway(优先 IPC)转发 runtime 请求
260+
- 连接或握手失败会直接退出(Fail Fast),不会自动回退到 `local`
256261

257262
## 常见错误
258263

internal/app/bootstrap.go

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package app
22

33
import (
44
"context"
5+
"errors"
56
"log"
67
"path/filepath"
78
"strings"
@@ -29,14 +30,23 @@ import (
2930
"neo-code/internal/tools/todo"
3031
"neo-code/internal/tools/webfetch"
3132
"neo-code/internal/tui"
33+
"neo-code/internal/tui/services"
3234
)
3335

3436
const utf8CodePage = 65001
3537

38+
const (
39+
// RuntimeModeLocal 表示继续使用进程内 runtime 直连模式。
40+
RuntimeModeLocal = "local"
41+
// RuntimeModeGateway 表示通过 Gateway JSON-RPC 转发 runtime 调用。
42+
RuntimeModeGateway = "gateway"
43+
)
44+
3645
var (
3746
setConsoleOutputCodePage = platformSetConsoleOutputCodePage
3847
setConsoleInputCodePage = platformSetConsoleInputCodePage
3948
buildToolManagerFunc = buildToolManager
49+
newRemoteRuntimeAdapter = defaultNewRemoteRuntimeAdapter
4050
newTUIWithMemo = tui.NewWithMemo
4151
cleanupExpiredSessions = func(
4252
ctx context.Context,
@@ -49,13 +59,19 @@ var (
4959

5060
// BootstrapOptions 描述应用启动时可注入的运行时选项。
5161
type BootstrapOptions struct {
52-
Workdir string
62+
Workdir string
63+
RuntimeMode string
5364
}
5465

5566
type memoExtractorScheduler interface {
5667
ScheduleWithExtractor(sessionID string, messages []providertypes.Message, extractor memo.Extractor)
5768
}
5869

70+
type runtimeWithClose interface {
71+
agentruntime.Runtime
72+
Close() error
73+
}
74+
5975
func newMemoExtractorAdapter(
6076
factory agentruntime.ProviderFactory,
6177
cm *config.Manager,
@@ -114,6 +130,11 @@ func EnsureConsoleUTF8() {
114130

115131
// BuildRuntime 构建 CLI 与 TUI 共用的运行时依赖。
116132
func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, error) {
133+
runtimeMode, err := resolveBootstrapRuntimeMode(opts.RuntimeMode)
134+
if err != nil {
135+
return RuntimeBundle{}, err
136+
}
137+
117138
defaultCfg, err := bootstrapDefaultConfig(opts)
118139
if err != nil {
119140
return RuntimeBundle{}, err
@@ -210,14 +231,26 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
210231
memo.NewAutoExtractor(nil, memoSvc, time.Duration(cfg.Memo.ExtractTimeoutSec)*time.Second),
211232
))
212233
}
234+
235+
runtimeImpl := agentruntime.Runtime(runtimeSvc)
236+
closeFns := []func() error{toolsCleanup, sessionStore.Close}
237+
if runtimeMode == RuntimeModeGateway {
238+
remoteRuntime, remoteErr := newRemoteRuntimeAdapter(services.RemoteRuntimeAdapterOptions{})
239+
if remoteErr != nil {
240+
return RuntimeBundle{}, remoteErr
241+
}
242+
runtimeImpl = remoteRuntime
243+
closeFns = append([]func() error{remoteRuntime.Close}, closeFns...)
244+
}
245+
213246
needCleanup = false
214247

215-
closeBundle := combineRuntimeClosers(toolsCleanup, sessionStore.Close)
248+
closeBundle := combineRuntimeClosers(closeFns...)
216249

217250
return RuntimeBundle{
218251
Config: cfg,
219252
ConfigManager: manager,
220-
Runtime: runtimeSvc,
253+
Runtime: runtimeImpl,
221254
ProviderSelection: providerSelection,
222255
MemoService: memoSvc,
223256
Close: closeBundle,
@@ -266,6 +299,20 @@ func resolveBootstrapWorkdir(workdir string) (string, error) {
266299
return agentsession.ResolveExistingDir(workdir)
267300
}
268301

302+
// resolveBootstrapRuntimeMode 归一化并校验 runtime 运行模式。
303+
func resolveBootstrapRuntimeMode(mode string) (string, error) {
304+
normalized := strings.ToLower(strings.TrimSpace(mode))
305+
if normalized == "" {
306+
return RuntimeModeLocal, nil
307+
}
308+
switch normalized {
309+
case RuntimeModeLocal, RuntimeModeGateway:
310+
return normalized, nil
311+
default:
312+
return "", errors.New("bootstrap: runtime mode must be local or gateway")
313+
}
314+
}
315+
269316
func buildToolRegistry(cfg config.Config) (*tools.Registry, func() error, error) {
270317
toolRegistry := tools.NewRegistry()
271318
toolRegistry.Register(filesystem.New(cfg.Workdir))
@@ -323,6 +370,15 @@ func buildMCPAgentExposureRules(configs []config.MCPAgentExposureConfig) []mcp.A
323370
return rules
324371
}
325372

373+
// defaultNewRemoteRuntimeAdapter 构建默认的 Gateway runtime 适配器。
374+
func defaultNewRemoteRuntimeAdapter(options services.RemoteRuntimeAdapterOptions) (runtimeWithClose, error) {
375+
adapter, err := services.NewRemoteRuntimeAdapter(options)
376+
if err != nil {
377+
return nil, err
378+
}
379+
return adapter, nil
380+
}
381+
326382
func buildToolManager(registry *tools.Registry) (tools.Manager, error) {
327383
engine, err := security.NewRecommendedPolicyEngine()
328384
if err != nil {

internal/app/bootstrap_test.go

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
"neo-code/internal/tools"
2929
"neo-code/internal/tools/mcp"
3030
"neo-code/internal/tui"
31+
"neo-code/internal/tui/services"
3132
)
3233

3334
func TestNewProgram(t *testing.T) {
@@ -1439,11 +1440,183 @@ func TestNewMemoExtractorAdapterPropagatesFactoryBuildError(t *testing.T) {
14391440
}
14401441
}
14411442

1443+
func TestResolveBootstrapRuntimeMode(t *testing.T) {
1444+
mode, err := resolveBootstrapRuntimeMode("")
1445+
if err != nil {
1446+
t.Fatalf("resolveBootstrapRuntimeMode() error = %v", err)
1447+
}
1448+
if mode != RuntimeModeLocal {
1449+
t.Fatalf("expected default mode %q, got %q", RuntimeModeLocal, mode)
1450+
}
1451+
1452+
mode, err = resolveBootstrapRuntimeMode(" GATEWAY ")
1453+
if err != nil {
1454+
t.Fatalf("resolveBootstrapRuntimeMode() error = %v", err)
1455+
}
1456+
if mode != RuntimeModeGateway {
1457+
t.Fatalf("expected gateway mode %q, got %q", RuntimeModeGateway, mode)
1458+
}
1459+
1460+
_, err = resolveBootstrapRuntimeMode("invalid")
1461+
if err == nil {
1462+
t.Fatalf("expected invalid runtime mode error")
1463+
}
1464+
}
1465+
1466+
func TestBuildRuntimeRejectsInvalidRuntimeMode(t *testing.T) {
1467+
t.Parallel()
1468+
1469+
_, err := BuildRuntime(context.Background(), BootstrapOptions{RuntimeMode: "invalid"})
1470+
if err == nil {
1471+
t.Fatalf("expected invalid runtime mode error")
1472+
}
1473+
}
1474+
1475+
func TestDefaultNewRemoteRuntimeAdapterReturnsInitError(t *testing.T) {
1476+
home := t.TempDir()
1477+
t.Setenv("HOME", home)
1478+
t.Setenv("USERPROFILE", home)
1479+
1480+
_, err := defaultNewRemoteRuntimeAdapter(services.RemoteRuntimeAdapterOptions{
1481+
ListenAddress: "ipc://127.0.0.1",
1482+
TokenFile: home + "/missing-token.json",
1483+
})
1484+
if err == nil {
1485+
t.Fatalf("expected defaultNewRemoteRuntimeAdapter to fail when token is missing")
1486+
}
1487+
}
1488+
1489+
func TestBuildRuntimeGatewayModeUsesRemoteAdapter(t *testing.T) {
1490+
disableBuiltinProviderAPIKeys(t)
1491+
1492+
home := t.TempDir()
1493+
t.Setenv("HOME", home)
1494+
t.Setenv("USERPROFILE", home)
1495+
1496+
originalFactory := newRemoteRuntimeAdapter
1497+
t.Cleanup(func() { newRemoteRuntimeAdapter = originalFactory })
1498+
1499+
stubRuntime := &stubRemoteRuntimeForBootstrap{
1500+
events: make(chan agentruntime.RuntimeEvent),
1501+
}
1502+
newRemoteRuntimeAdapter = func(_ services.RemoteRuntimeAdapterOptions) (runtimeWithClose, error) {
1503+
return stubRuntime, nil
1504+
}
1505+
1506+
bundle, err := BuildRuntime(context.Background(), BootstrapOptions{RuntimeMode: RuntimeModeGateway})
1507+
if err != nil {
1508+
t.Fatalf("BuildRuntime() error = %v", err)
1509+
}
1510+
if bundle.Runtime != stubRuntime {
1511+
t.Fatalf("expected gateway runtime adapter to be wired")
1512+
}
1513+
if bundle.Close == nil {
1514+
t.Fatalf("expected non-nil close function")
1515+
}
1516+
if err := bundle.Close(); err != nil {
1517+
t.Fatalf("bundle.Close() error = %v", err)
1518+
}
1519+
if !stubRuntime.closed {
1520+
t.Fatalf("expected remote runtime close to be called")
1521+
}
1522+
}
1523+
1524+
func TestBuildRuntimeGatewayModeFailsFastWhenAdapterInitFails(t *testing.T) {
1525+
disableBuiltinProviderAPIKeys(t)
1526+
1527+
home := t.TempDir()
1528+
t.Setenv("HOME", home)
1529+
t.Setenv("USERPROFILE", home)
1530+
1531+
originalFactory := newRemoteRuntimeAdapter
1532+
t.Cleanup(func() { newRemoteRuntimeAdapter = originalFactory })
1533+
1534+
newRemoteRuntimeAdapter = func(_ services.RemoteRuntimeAdapterOptions) (runtimeWithClose, error) {
1535+
return nil, errors.New("gateway connect failed")
1536+
}
1537+
1538+
_, err := BuildRuntime(context.Background(), BootstrapOptions{RuntimeMode: RuntimeModeGateway})
1539+
if err == nil {
1540+
t.Fatalf("expected gateway mode fail-fast error")
1541+
}
1542+
if !strings.Contains(err.Error(), "gateway connect failed") {
1543+
t.Fatalf("unexpected error: %v", err)
1544+
}
1545+
}
1546+
14421547
type stubToolForBootstrap struct {
14431548
name string
14441549
content string
14451550
}
14461551

1552+
type stubRemoteRuntimeForBootstrap struct {
1553+
closed bool
1554+
events chan agentruntime.RuntimeEvent
1555+
}
1556+
1557+
func (s *stubRemoteRuntimeForBootstrap) Submit(context.Context, agentruntime.PrepareInput) error {
1558+
return nil
1559+
}
1560+
1561+
func (s *stubRemoteRuntimeForBootstrap) PrepareUserInput(
1562+
context.Context,
1563+
agentruntime.PrepareInput,
1564+
) (agentruntime.UserInput, error) {
1565+
return agentruntime.UserInput{}, nil
1566+
}
1567+
1568+
func (s *stubRemoteRuntimeForBootstrap) Run(context.Context, agentruntime.UserInput) error {
1569+
return nil
1570+
}
1571+
1572+
func (s *stubRemoteRuntimeForBootstrap) Compact(context.Context, agentruntime.CompactInput) (agentruntime.CompactResult, error) {
1573+
return agentruntime.CompactResult{}, nil
1574+
}
1575+
1576+
func (s *stubRemoteRuntimeForBootstrap) ExecuteSystemTool(
1577+
context.Context,
1578+
agentruntime.SystemToolInput,
1579+
) (tools.ToolResult, error) {
1580+
return tools.ToolResult{}, nil
1581+
}
1582+
1583+
func (s *stubRemoteRuntimeForBootstrap) ResolvePermission(context.Context, agentruntime.PermissionResolutionInput) error {
1584+
return nil
1585+
}
1586+
1587+
func (s *stubRemoteRuntimeForBootstrap) CancelActiveRun() bool {
1588+
return false
1589+
}
1590+
1591+
func (s *stubRemoteRuntimeForBootstrap) Events() <-chan agentruntime.RuntimeEvent {
1592+
return s.events
1593+
}
1594+
1595+
func (s *stubRemoteRuntimeForBootstrap) ListSessions(context.Context) ([]agentsession.Summary, error) {
1596+
return nil, nil
1597+
}
1598+
1599+
func (s *stubRemoteRuntimeForBootstrap) LoadSession(context.Context, string) (agentsession.Session, error) {
1600+
return agentsession.Session{}, nil
1601+
}
1602+
1603+
func (s *stubRemoteRuntimeForBootstrap) ActivateSessionSkill(context.Context, string, string) error {
1604+
return nil
1605+
}
1606+
1607+
func (s *stubRemoteRuntimeForBootstrap) DeactivateSessionSkill(context.Context, string, string) error {
1608+
return nil
1609+
}
1610+
1611+
func (s *stubRemoteRuntimeForBootstrap) ListSessionSkills(context.Context, string) ([]agentruntime.SessionSkillState, error) {
1612+
return nil, nil
1613+
}
1614+
1615+
func (s *stubRemoteRuntimeForBootstrap) Close() error {
1616+
s.closed = true
1617+
return nil
1618+
}
1619+
14471620
func (s stubToolForBootstrap) Name() string { return s.name }
14481621
func (s stubToolForBootstrap) Description() string { return "stub" }
14491622
func (s stubToolForBootstrap) Schema() map[string]any { return map[string]any{"type": "object"} }

0 commit comments

Comments
 (0)