Skip to content
Merged
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
66 changes: 66 additions & 0 deletions docs/reference/tui-gateway-contract-matrix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# TUI-Gateway Contract Matrix (Single-Version Baseline)

This document freezes the contract that TUI consumes from gateway.
It is intentionally single-version and fail-fast by design.

## Scope

- Transport contract: JSON-RPC 2.0 (`internal/gateway/protocol`)
- Runtime contract: gateway DTOs (`internal/gateway/contracts.go`)
- Event payload version source of truth: `internal/runtime/controlplane/envelope.go`

## RPC Methods Used By TUI

| Method | Params Type | Result Payload | Notes |
| --- | --- | --- | --- |
| `gateway.authenticate` | `protocol.AuthenticateParams` | frame ack | Must succeed before runtime actions |
| `gateway.bindStream` | `protocol.BindStreamParams` | frame ack | Binds session/run event stream |
| `gateway.run` | `protocol.RunParams` | frame ack with `session_id`/`run_id` | Async acceptance only |
| `gateway.compact` | `protocol.CompactParams` | `gateway.CompactResult` | Manual compact |
| `gateway.executeSystemTool` | `protocol.ExecuteSystemToolParams` | `tools.ToolResult` | Tool execution passthrough |
| `gateway.resolvePermission` | `protocol.ResolvePermissionParams` | frame ack | Permission decision submit |
| `gateway.cancel` | `protocol.CancelParams` | frame ack | Cancels run by run/session binding |
| `gateway.listSessions` | none | `[]gateway.SessionSummary` | Session list |
| `gateway.loadSession` | `protocol.LoadSessionParams` | `gateway.Session` | Full session snapshot |
| `gateway.activateSessionSkill` | `protocol.ActivateSessionSkillParams` | frame ack | Activate skill in session |
| `gateway.deactivateSessionSkill` | `protocol.DeactivateSessionSkillParams` | frame ack | Deactivate skill in session |
| `gateway.listSessionSkills` | `protocol.ListSessionSkillsParams` | `[]gateway.SessionSkillState` | Active skill states |
| `gateway.listAvailableSkills` | `protocol.ListAvailableSkillsParams` | `[]gateway.AvailableSkillState` | Available skill catalog |

## Runtime Event Contract

- Notification method: `gateway.event`
- TUI only accepts a runtime envelope payload with these required keys:
- `runtime_event_type` (string)
- `turn` (number)
- `phase` (string)
- `timestamp` (RFC3339 or RFC3339Nano)
- `payload_version` (number)
- `payload` (event-specific object/string)
- `payload_version` must equal `controlplane.PayloadVersion`.
- Version mismatch is treated as a hard incompatibility and must fail fast.

## Error Contract

TUI consumes standard JSON-RPC errors and gateway extended error codes from
`protocol.JSONRPCError.Data.GatewayCode`.

Primary gateway codes used for UI mapping:

- `invalid_frame`
- `invalid_action`
- `invalid_multimodal_payload`
- `missing_required_field`
- `unsupported_action`
- `internal_error`
- `timeout`
- `unsafe_path`
- `unauthorized`
- `access_denied`
- `resource_not_found`

## Non-Goals

- No multi-version payload decoding.
- No alias method fallback.
- No legacy field fallback in event payload.
19 changes: 19 additions & 0 deletions internal/cli/gateway_runtime_bridge_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2627,3 +2627,22 @@ func TestGatewayRuntimePortBridgeDeleteMCPServerSuccess(t *testing.T) {
t.Fatalf("servers = %+v, want [srv-2]", cfgMgr.cfg.Tools.MCP.Servers)
}
}

func TestDefaultBuildGatewayRuntimePortListSessionsWithoutExplicitWorkdir(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))

port, cleanup, err := defaultBuildGatewayRuntimePort(context.Background(), "")
if err != nil {
t.Fatalf("defaultBuildGatewayRuntimePort() error = %v", err)
}
if cleanup != nil {
defer func() { _ = cleanup() }()
}

if _, err := port.ListSessions(context.Background()); err != nil {
t.Fatalf("ListSessions() with empty cli workdir should succeed, got %v", err)
}
}
35 changes: 27 additions & 8 deletions internal/gateway/multi_workspace_runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import (
// MultiWorkspaceRuntime 将多个工作区的 runtime 聚合为单个 gateway.RuntimePort。
// 根据连接上下文中的 workspaceHash 路由到对应工作区的 runtime。
type MultiWorkspaceRuntime struct {
index *agentsession.WorkspaceIndex
bundles map[string]*workspaceBundle
mu sync.RWMutex
buildPort func(ctx context.Context, workdir string) (RuntimePort, func() error, error)
defaultHash string
index *agentsession.WorkspaceIndex
bundles map[string]*workspaceBundle
mu sync.RWMutex
buildPort func(ctx context.Context, workdir string) (RuntimePort, func() error, error)
defaultHash string
managementPort ManagementRuntimePort

events chan RuntimeEvent
Expand Down Expand Up @@ -55,16 +55,28 @@ func NewMultiWorkspaceRuntime(
func (m *MultiWorkspaceRuntime) getPort(ctx context.Context) (RuntimePort, error) {
hash := WorkspaceHashFromContext(ctx)
if hash == "" {
m.mu.RLock()
hash = m.defaultHash
m.mu.RUnlock()
}
if hash == "" {
// Support startup flows where gateway preloads a default runtime bundle
// but no explicit workspace hash has been persisted yet.
m.mu.RLock()
if preloaded := m.bundles[""]; preloaded != nil {
port := preloaded.port
m.mu.RUnlock()
return port, nil
}
m.mu.RUnlock()

records := m.index.List()
if len(records) > 0 {
hash = records[0].Hash
}
}
if hash == "" {
return nil, fmt.Errorf("workspace hash is empty and no default configured")
return nil, fmt.Errorf("%w: workspace hash is empty and no default configured", ErrRuntimeResourceNotFound)
}
return m.getPortForHash(hash)
}
Expand All @@ -86,7 +98,7 @@ func (m *MultiWorkspaceRuntime) getPortForHash(hash string) (RuntimePort, error)

record, ok := m.index.Get(hash)
if !ok {
return nil, fmt.Errorf("workspace %s not found", hash)
return nil, fmt.Errorf("%w: workspace %s not found", ErrRuntimeResourceNotFound, hash)
}

port, cleanup, err := m.buildPort(context.Background(), record.Path)
Expand Down Expand Up @@ -161,7 +173,7 @@ func (m *MultiWorkspaceRuntime) Close() error {
func (m *MultiWorkspaceRuntime) SwitchWorkspace(ctx context.Context, hash string) error {
_, ok := m.index.Get(hash)
if !ok {
return fmt.Errorf("workspace %s not found", hash)
return fmt.Errorf("%w: workspace %s not found", ErrRuntimeResourceNotFound, hash)
}
// 预加载对应 runtime,确保后续请求可用
if _, err := m.getPortForHash(hash); err != nil {
Expand Down Expand Up @@ -210,6 +222,13 @@ func (m *MultiWorkspaceRuntime) DeleteWorkspace(hash string, removeData bool) er
if ok {
delete(m.bundles, hash)
}
if strings.EqualFold(strings.TrimSpace(hash), strings.TrimSpace(m.defaultHash)) {
m.defaultHash = ""
records := m.index.List()
if len(records) > 0 {
m.defaultHash = strings.TrimSpace(records[0].Hash)
}
}
m.mu.Unlock()

if ok && b != nil && b.cleanup != nil {
Expand Down
46 changes: 46 additions & 0 deletions internal/gateway/multi_workspace_runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,12 +286,35 @@ func TestMultiWorkspaceRuntime_NoHashConfigured(t *testing.T) {

if _, err := mw.ListSessions(context.Background()); err == nil {
t.Fatalf("expected error when no hash is configured")
} else if !errors.Is(err, ErrRuntimeResourceNotFound) {
t.Fatalf("expected ErrRuntimeResourceNotFound, got %v", err)
}
if got := builder.callCount(); got != 0 {
t.Fatalf("buildPort should not be called when no hash, got %d", got)
}
}

func TestMultiWorkspaceRuntime_NoHashUsesPreloadedAnonymousBundle(t *testing.T) {
idx := agentsession.NewWorkspaceIndex(t.TempDir())
builder := newTestBuilder()

mw := NewMultiWorkspaceRuntime(idx, "", builder.build)
t.Cleanup(func() { _ = mw.Close() })

preloaded := newRecordingPort("anonymous-default")
mw.PreloadWorkspaceBundle("", preloaded, preloaded.cleanup)

if _, err := mw.ListSessions(context.Background()); err != nil {
t.Fatalf("ListSessions with anonymous preloaded bundle: %v", err)
}
if got := preloaded.listSessionsCalls.Load(); got != 1 {
t.Fatalf("anonymous preloaded listSessions calls = %d, want 1", got)
}
if got := builder.callCount(); got != 0 {
t.Fatalf("buildPort should not be called when anonymous preloaded bundle exists; got %d", got)
}
}

func TestMultiWorkspaceRuntime_ContextHashOverridesDefault(t *testing.T) {
idx, alpha, beta := setupIndex(t)
builder := newTestBuilder()
Expand Down Expand Up @@ -356,6 +379,8 @@ func TestMultiWorkspaceRuntime_UnknownHashErrors(t *testing.T) {
_, err := mw.ListSessions(ctxWithHash(t, "deadbeef"))
if err == nil {
t.Fatalf("expected error for unknown hash")
} else if !errors.Is(err, ErrRuntimeResourceNotFound) {
t.Fatalf("expected ErrRuntimeResourceNotFound, got %v", err)
}
if got := builder.callCount(); got != 0 {
t.Fatalf("buildPort should not be invoked for unknown hash; got %d", got)
Expand Down Expand Up @@ -510,6 +535,27 @@ func TestMultiWorkspaceRuntime_RenameAndDeletePersist(t *testing.T) {
}
}

func TestMultiWorkspaceRuntime_DeleteDefaultHashFallsBackToRemainingWorkspace(t *testing.T) {
idx, alpha, beta := setupIndex(t)
builder := newTestBuilder()
mw := NewMultiWorkspaceRuntime(idx, alpha.Hash, builder.build)
t.Cleanup(func() { _ = mw.Close() })

if err := mw.DeleteWorkspace(alpha.Hash, false); err != nil {
t.Fatalf("Delete default workspace: %v", err)
}

if _, err := mw.ListSessions(context.Background()); err != nil {
t.Fatalf("ListSessions fallback after deleting default: %v", err)
}
if builder.portFor(alpha.Path) != nil {
t.Fatalf("alpha port should not be rebuilt after delete")
}
if builder.portFor(beta.Path) == nil {
t.Fatalf("expected fallback to remaining workspace beta")
}
}

func TestMultiWorkspaceRuntime_DeleteUnknownErrors(t *testing.T) {
idx, alpha, _ := setupIndex(t)
builder := newTestBuilder()
Expand Down
12 changes: 11 additions & 1 deletion internal/gateway/workspace_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,17 @@ func handleWorkspaceDeleteFrame(ctx context.Context, frame MessageFrame, runtime
if deleteErr := mw.DeleteWorkspace(params.Hash, params.RemoveData); deleteErr != nil {
return errorFrame(frame, NewFrameError(ErrorCodeInternalError, deleteErr.Error()))
}
if wsState, ok := ConnectionWorkspaceStateFromContext(ctx); ok {
activeHash := strings.TrimSpace(wsState.GetWorkspaceHash())
if strings.EqualFold(activeHash, strings.TrimSpace(params.Hash)) {
wsState.SetWorkspaceHash("")
if relay, relayOK := StreamRelayFromContext(ctx); relayOK {
if connID, connOK := ConnectionIDFromContext(ctx); connOK {
relay.ClearConnectionBindings(connID)
}
}
}
}

return MessageFrame{
Type: FrameTypeAck,
Expand Down Expand Up @@ -270,4 +281,3 @@ func decodeWorkspaceDeletePayload(payload any) (workspaceDeleteParams, *FrameErr
return workspaceDeleteParams{}, NewFrameError(ErrorCodeInvalidFrame, "invalid workspace.delete payload")
}
}

73 changes: 73 additions & 0 deletions internal/gateway/workspace_handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package gateway

import (
"context"
"testing"

"neo-code/internal/gateway/protocol"
)

func TestHandleWorkspaceDeleteFrameClearsActiveWorkspaceStateAndBindings(t *testing.T) {
idx, alpha, _ := setupIndex(t)
builder := newTestBuilder()
mw := NewMultiWorkspaceRuntime(idx, alpha.Hash, builder.build)
t.Cleanup(func() { _ = mw.Close() })

relay := NewStreamRelay(StreamRelayOptions{})
connID := NewConnectionID()
registerErr := relay.RegisterConnection(ConnectionRegistration{
ConnectionID: connID,
Channel: StreamChannelIPC,
Context: context.Background(),
Cancel: func() {},
Write: func(message RelayMessage) error {
return nil
},
Close: func() {},
})
if registerErr != nil {
t.Fatalf("register connection: %v", registerErr)
}
t.Cleanup(func() { relay.dropConnection(connID) })

if bindErr := relay.BindConnection(connID, StreamBinding{
SessionID: "session-delete-check",
Channel: StreamChannelAll,
Explicit: true,
}); bindErr != nil {
t.Fatalf("bind connection: %v", bindErr)
}

wsState := NewConnectionWorkspaceState()
wsState.SetWorkspaceHash(alpha.Hash)
ctx := WithConnectionID(
WithStreamRelay(
WithConnectionWorkspaceState(context.Background(), wsState),
relay,
),
connID,
)

response := handleWorkspaceDeleteFrame(ctx, MessageFrame{
Type: FrameTypeRequest,
Action: FrameActionWorkspaceDelete,
RequestID: "workspace-delete-active",
Payload: protocol.DeleteWorkspaceParams{
WorkspaceHash: alpha.Hash,
},
}, mw)
if response.Type != FrameTypeAck {
t.Fatalf("response type = %q, want %q", response.Type, FrameTypeAck)
}

if got := wsState.GetWorkspaceHash(); got != "" {
t.Fatalf("workspace hash should be cleared after deleting active workspace, got %q", got)
}

relay.mu.RLock()
_, exists := relay.connectionBindings[NormalizeConnectionID(connID)]
relay.mu.RUnlock()
if exists {
t.Fatalf("connection bindings should be cleared after deleting active workspace")
}
}
1 change: 1 addition & 0 deletions internal/runtime/session_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type SessionLogEntry struct {
Level string `json:"level"`
Source string `json:"source"`
Message string `json:"message"`
Inline string `json:"inline_message,omitempty"`
}

// LoadSessionLogEntries 按会话 ID 读取日志查看器持久化数据。
Expand Down
4 changes: 4 additions & 0 deletions internal/tui/core/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type logEntry struct {
Level string
Source string
Message string
Inline string
}

type panel = tuistate.Panel
Expand Down Expand Up @@ -160,6 +161,9 @@ type appRuntimeState struct {
logPersistDirty bool
logPersistVersion int
transcriptContent string
transcriptProcessFoldAvailable bool
transcriptProcessExpanded bool
transcriptProcessExpandedOrdinal int
transcriptScrollbarDrag bool
startupScreenLocked bool
suppressAssistantForRun string
Expand Down
Loading
Loading