From ef2ee8820c5a8f5d4ebeb7e44bd3ae6eb6a8808c Mon Sep 17 00:00:00 2001 From: AzureLandin Date: Wed, 21 Oct 2026 13:24:02 +0800 Subject: [PATCH 1/5] refactor: move Wails app facade into internal/app Move app.go, external.go, wails_sink.go and the facade tests into internal/app. Keep startup/shutdown as package functions so Wails does not bind them, and update the frontend bridge to the generated window.go.app.App namespace. --- app.go => internal/app/app.go | 20 ++++++++++++++-- .../app/app_agent_test.go | 2 +- .../app/app_credentials_test.go | 2 +- .../app/app_mcpregistration_test.go | 2 +- .../app/app_monitor_test.go | 2 +- .../app/app_sessions_test.go | 2 +- .../app/app_sftp_test.go | 2 +- .../app/app_system_test.go | 8 +++++-- app_test.go => internal/app/app_test.go | 2 +- .../app/app_tunnel_test.go | 2 +- external.go => internal/app/external.go | 2 +- wails_sink.go => internal/app/wails_sink.go | 2 +- internal/mcpcli/mcp.go | 2 +- main.go | 9 ++++---- src/renderer/src/api/bridge.ts | 12 +++++----- tests/api-adapter.test.ts | 23 +++++++++++++++++++ 16 files changed, 69 insertions(+), 25 deletions(-) rename app.go => internal/app/app.go (98%) rename app_agent_test.go => internal/app/app_agent_test.go (99%) rename app_credentials_test.go => internal/app/app_credentials_test.go (99%) rename app_mcpregistration_test.go => internal/app/app_mcpregistration_test.go (99%) rename app_monitor_test.go => internal/app/app_monitor_test.go (99%) rename app_sessions_test.go => internal/app/app_sessions_test.go (99%) rename app_sftp_test.go => internal/app/app_sftp_test.go (99%) rename app_system_test.go => internal/app/app_system_test.go (97%) rename app_test.go => internal/app/app_test.go (99%) rename app_tunnel_test.go => internal/app/app_tunnel_test.go (99%) rename external.go => internal/app/external.go (99%) rename wails_sink.go => internal/app/wails_sink.go (98%) diff --git a/app.go b/internal/app/app.go similarity index 98% rename from app.go rename to internal/app/app.go index 58020fc..3d3fb7c 100644 --- a/app.go +++ b/internal/app/app.go @@ -1,4 +1,6 @@ -package main +// Package app wires the Wails-bound application facade to NodeShell's domain +// services. It is the only internal package that imports the Wails runtime. +package app import ( "context" @@ -309,6 +311,20 @@ func logRollbackFailure(err error) { fmt.Fprintf(os.Stderr, "nodeshell: failed to restore previous credentials: %v\n", err) } +// Start runs startup wiring for a Wails-created App. It is a package function +// rather than a method so Wails does not expose lifecycle control to the +// frontend. +func Start(a *App, ctx context.Context) { + a.startup(ctx) +} + +// Stop runs shutdown cleanup for a Wails-created App. It is a package function +// rather than a method so Wails does not expose lifecycle control to the +// frontend. +func Stop(a *App, ctx context.Context) { + a.shutdown(ctx) +} + // startup is invoked by Wails once the WebView is initialised. It resolves // and creates the data directory and wires the domain services. Failures are // written to stderr; bound methods keep failing observably until services @@ -1500,7 +1516,7 @@ func (a *App) DialogOpenPrivateKeyFile() (string, error) { // appVersion is the version shown on the About page. Keep it in sync with // wails.json info.productVersion and package.json version (enforced by // TestAppVersionMatchesManifests); a build can override it with -// -ldflags "-X main.appVersion=...". +// -ldflags "-X nodeshell/internal/app.appVersion=...". var appVersion = "2.1.0" // FontsList returns the system font families for the Settings modal. The diff --git a/app_agent_test.go b/internal/app/app_agent_test.go similarity index 99% rename from app_agent_test.go rename to internal/app/app_agent_test.go index 4bd6c09..1ab0897 100644 --- a/app_agent_test.go +++ b/internal/app/app_agent_test.go @@ -1,4 +1,4 @@ -package main +package app import ( "errors" diff --git a/app_credentials_test.go b/internal/app/app_credentials_test.go similarity index 99% rename from app_credentials_test.go rename to internal/app/app_credentials_test.go index e7ca422..9f89938 100644 --- a/app_credentials_test.go +++ b/internal/app/app_credentials_test.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" diff --git a/app_mcpregistration_test.go b/internal/app/app_mcpregistration_test.go similarity index 99% rename from app_mcpregistration_test.go rename to internal/app/app_mcpregistration_test.go index f01c6cb..6dab7bb 100644 --- a/app_mcpregistration_test.go +++ b/internal/app/app_mcpregistration_test.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" diff --git a/app_monitor_test.go b/internal/app/app_monitor_test.go similarity index 99% rename from app_monitor_test.go rename to internal/app/app_monitor_test.go index 16288f5..5674c0c 100644 --- a/app_monitor_test.go +++ b/internal/app/app_monitor_test.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" diff --git a/app_sessions_test.go b/internal/app/app_sessions_test.go similarity index 99% rename from app_sessions_test.go rename to internal/app/app_sessions_test.go index de8e6c1..e5e21ba 100644 --- a/app_sessions_test.go +++ b/internal/app/app_sessions_test.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" diff --git a/app_sftp_test.go b/internal/app/app_sftp_test.go similarity index 99% rename from app_sftp_test.go rename to internal/app/app_sftp_test.go index 478b556..5b42cb6 100644 --- a/app_sftp_test.go +++ b/internal/app/app_sftp_test.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" diff --git a/app_system_test.go b/internal/app/app_system_test.go similarity index 97% rename from app_system_test.go rename to internal/app/app_system_test.go index 263a397..76ee638 100644 --- a/app_system_test.go +++ b/internal/app/app_system_test.go @@ -1,10 +1,11 @@ -package main +package app import ( "context" "encoding/json" "errors" "os" + "path/filepath" "reflect" "testing" ) @@ -76,7 +77,10 @@ func TestAppVersionMatchesManifests(t *testing.T) { var pkgCfg struct { Version string `json:"version"` } - for file, v := range map[string]any{"wails.json": &wailsCfg, "package.json": &pkgCfg} { + for file, v := range map[string]any{ + filepath.Join("..", "..", "wails.json"): &wailsCfg, + filepath.Join("..", "..", "package.json"): &pkgCfg, + } { raw, err := os.ReadFile(file) if err != nil { t.Fatalf("read %s: %v", file, err) diff --git a/app_test.go b/internal/app/app_test.go similarity index 99% rename from app_test.go rename to internal/app/app_test.go index fa65b90..969b2e7 100644 --- a/app_test.go +++ b/internal/app/app_test.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" diff --git a/app_tunnel_test.go b/internal/app/app_tunnel_test.go similarity index 99% rename from app_tunnel_test.go rename to internal/app/app_tunnel_test.go index 332ed73..64e1aa9 100644 --- a/app_tunnel_test.go +++ b/internal/app/app_tunnel_test.go @@ -1,4 +1,4 @@ -package main +package app import ( "net" diff --git a/external.go b/internal/app/external.go similarity index 99% rename from external.go rename to internal/app/external.go index ff289a8..515f6db 100644 --- a/external.go +++ b/internal/app/external.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" diff --git a/wails_sink.go b/internal/app/wails_sink.go similarity index 98% rename from wails_sink.go rename to internal/app/wails_sink.go index ab41743..6d098ef 100644 --- a/wails_sink.go +++ b/internal/app/wails_sink.go @@ -1,4 +1,4 @@ -package main +package app import ( "context" diff --git a/internal/mcpcli/mcp.go b/internal/mcpcli/mcp.go index e9d8a9b..5be09d5 100644 --- a/internal/mcpcli/mcp.go +++ b/internal/mcpcli/mcp.go @@ -73,7 +73,7 @@ func shutdownMCP(rt *Runtime, m ManagerShutdown) { rt.DisposeAll() } -// OS seams for RunMCP (test injection), mirroring the app.go seam pattern so +// OS seams for RunMCP (test injection), mirroring the internal/app seam pattern so // the production entry is testable without touching the real user profile. var ( resolveDataDir = configdir.DataDir diff --git a/main.go b/main.go index 70fe39d..030f84d 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "nodeshell/internal/app" "nodeshell/internal/apperror" "nodeshell/internal/mcpcli" ) @@ -79,7 +80,7 @@ func runGUI() int { // stdin/stdout. Detach that console for the GUI entry so a terminal does // not flash when the user launches the desktop app. detachConsole() - app := NewApp() + application := app.NewApp() err := wails.Run(&options.App{ Title: "NodeShell", Width: fallbackWindowWidth, @@ -105,18 +106,18 @@ func runGUI() int { DisableWebViewDrop: false, }, OnStartup: func(ctx context.Context) { - app.startup(ctx) + app.Start(application, ctx) applyInitialWindowSize(ctx) }, // Dispose all SSH sessions and in-flight connects before the WebView // tears down, so no goroutine outlives the runtime context. - OnShutdown: app.shutdown, + OnShutdown: func(ctx context.Context) { app.Stop(application, ctx) }, // Encode domain error codes as NODESHELL_ERR:: so the // frontend parser (src/shared/ipc-error.ts) recovers the code; the // default formatter would strip it down to the message only. ErrorFormatter: apperror.Format, Bind: []interface{}{ - app, + application, }, }) if err != nil { diff --git a/src/renderer/src/api/bridge.ts b/src/renderer/src/api/bridge.ts index ca95f89..217f3cd 100644 --- a/src/renderer/src/api/bridge.ts +++ b/src/renderer/src/api/bridge.ts @@ -2,12 +2,12 @@ * ApiBridge abstracts the Wails runtime surface the adapter talks to. * * Wails injects two objects into the WebView window: - * - `window.go.main.App`: the generated Go binding methods (one property per + * - `window.go.app.App`: the generated Go binding methods (one property per * exported App method), and * - `window.runtime`: runtime helpers such as EventsOn/EventsOff. * - * The generated `wailsjs/go/main/App.js` files are themselves just thin - * re-exports of `window.go.main.App`, so the bridge calls that runtime object + * The generated `go/app/App.js` bindings are themselves just thin + * re-exports of `window.go.app.App`, so the bridge calls that runtime object * directly. This keeps the adapter free of hand-maintained copies of the * generated binding interface (the artifacts are produced by `wails dev` / * `wails build`). When the generated bindings are absent (unit tests, plain @@ -56,11 +56,11 @@ interface WailsRuntime { } function wailsWindow(): { - go?: { main?: { App?: GoApp } } + go?: { app?: { App?: GoApp } } runtime?: WailsRuntime } { if (typeof window === 'undefined') return {} - return window as { go?: { main?: { App?: GoApp } }; runtime?: WailsRuntime } + return window as { go?: { app?: { App?: GoApp } }; runtime?: WailsRuntime } } /** @@ -70,7 +70,7 @@ function wailsWindow(): { */ export class WailsBridge implements ApiBridge { async call(method: string, ...args: unknown[]): Promise { - const app = wailsWindow().go?.main?.App + const app = wailsWindow().go?.app?.App const fn = app?.[method] if (typeof fn !== 'function') { throw notImplementedError(method) diff --git a/tests/api-adapter.test.ts b/tests/api-adapter.test.ts index f326cd4..f052426 100644 --- a/tests/api-adapter.test.ts +++ b/tests/api-adapter.test.ts @@ -740,6 +740,29 @@ describe('api adapter observable failures', () => { }) }) +describe('WailsBridge binding namespace', () => { + const originalWindow = (globalThis as { window?: unknown }).window + + afterEach(() => { + if (originalWindow === undefined) { + delete (globalThis as { window?: unknown }).window + } else { + ;(globalThis as { window: unknown }).window = originalWindow + } + }) + + it('calls the App facade under window.go.app', async () => { + const hostsList = vi.fn().mockResolvedValue([]) + ;(globalThis as { window?: unknown }).window = { + go: { app: { App: { HostsList: hostsList } } } + } + + const bridge = new WailsBridge() + await expect(bridge.call('HostsList')).resolves.toEqual([]) + expect(hostsList).toHaveBeenCalledTimes(1) + }) +}) + describe('WailsBridge against a bare node environment', () => { it('fails explicitly with NOT_IMPLEMENTED when generated bindings are absent', async () => { const bridge = new WailsBridge() From 2a2d032fd7c7ad57c7faa070ea9149d6188b2716 Mon Sep 17 00:00:00 2001 From: AzureLandin Date: Wed, 21 Oct 2026 13:24:07 +0800 Subject: [PATCH 2/5] docs: organize design and implementation reports Move the Wails rebuild spec under docs/design and group historical reports by feature domain under docs/reports. Add a docs index, update README/AGENTS paths, and fix cross-report links. --- AGENTS.md | 63 +++++++++++++++++++ README.md | 8 ++- docs/README.md | 21 +++++++ .../{compose/spec => design}/wails-rebuild.md | 0 ...14\346\224\266\346\212\245\345\221\212.md" | 2 +- ...36\346\226\275\350\256\241\345\210\222.md" | 0 ...14\346\224\266\346\212\245\345\221\212.md" | 6 +- ...56\346\224\271\350\256\241\345\210\222.md" | 0 ...14\346\224\266\346\212\245\345\221\212.md" | 2 +- ...36\346\226\275\350\256\241\345\210\222.md" | 0 ...14\346\224\266\346\212\245\345\221\212.md" | 6 +- ...36\346\226\275\350\256\241\345\210\222.md" | 0 ...30\345\214\226\350\256\241\345\210\222.md" | 0 ...14\346\224\266\346\212\245\345\221\212.md" | 4 +- ...47\350\203\275\345\237\272\347\272\277.md" | 0 ...36\346\226\275\350\256\241\345\210\222.md" | 0 ...56\345\244\215\345\273\272\350\256\256.md" | 0 ...14\346\224\266\346\212\245\345\221\212.md" | 2 +- ...36\346\226\275\350\256\241\345\210\222.md" | 0 ...14\346\224\266\346\212\245\345\221\212.md" | 6 +- ...56\345\244\215\346\212\245\345\221\212.md" | 26 ++++---- ...56\345\244\215\350\256\241\345\210\222.md" | 0 ...14\346\224\266\346\212\245\345\221\212.md" | 8 +-- ...56\345\244\215\350\256\241\345\210\222.md" | 0 ...14\346\224\266\346\212\245\345\221\212.md" | 2 +- ...36\346\226\275\350\256\241\345\210\222.md" | 0 ...14\346\224\266\346\212\245\345\221\212.md" | 2 +- ...36\346\226\275\350\256\241\345\210\222.md" | 0 ...14\346\224\266\346\212\245\345\221\212.md" | 2 +- ...36\346\226\275\350\256\241\345\210\222.md" | 0 ...14\346\224\266\346\212\245\345\221\212.md" | 2 +- ...36\346\226\275\350\256\241\345\210\222.md" | 0 ...36\346\226\275\346\212\245\345\221\212.md" | 0 33 files changed, 124 insertions(+), 38 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/README.md rename docs/{compose/spec => design}/wails-rebuild.md (100%) rename "reports/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" => "docs/reports/agent/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" (98%) rename "reports/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" => "docs/reports/agent/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" (100%) rename "reports/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" => "docs/reports/agent/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" (91%) rename "reports/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\345\256\236\347\216\260\350\257\264\346\230\216\344\270\216\344\277\256\346\224\271\350\256\241\345\210\222.md" => "docs/reports/agent/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\345\256\236\347\216\260\350\257\264\346\230\216\344\270\216\344\277\256\346\224\271\350\256\241\345\210\222.md" (100%) rename "reports/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" => "docs/reports/mcp/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" (98%) rename "reports/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" => "docs/reports/mcp/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" (100%) rename "reports/xterm.js_P0\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" => "docs/reports/terminal/xterm.js_P0\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" (86%) rename "reports/xterm.js_P0\344\274\230\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" => "docs/reports/terminal/xterm.js_P0\344\274\230\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" (100%) rename "reports/xterm.js_P0\347\250\263\345\256\232\346\200\247\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226\350\256\241\345\210\222.md" => "docs/reports/terminal/xterm.js_P0\347\250\263\345\256\232\346\200\247\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226\350\256\241\345\210\222.md" (100%) rename "reports/xterm.js_P2\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" => "docs/reports/terminal/xterm.js_P2\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" (97%) rename "reports/xterm.js_P2\344\274\230\345\214\226_\346\200\247\350\203\275\345\237\272\347\272\277.md" => "docs/reports/terminal/xterm.js_P2\344\274\230\345\214\226_\346\200\247\350\203\275\345\237\272\347\272\277.md" (100%) rename "reports/xterm.js_P2\344\274\230\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" => "docs/reports/terminal/xterm.js_P2\344\274\230\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" (100%) rename "reports/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_bug\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\345\273\272\350\256\256.md" => "docs/reports/terminal/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_bug\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\345\273\272\350\256\256.md" (100%) rename "reports/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" => "docs/reports/terminal/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" (98%) rename "reports/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\214\346\225\264\344\274\230\345\214\226\345\256\236\346\226\275\350\256\241\345\210\222.md" => "docs/reports/transfer/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\214\346\225\264\344\274\230\345\214\226\345\256\236\346\226\275\350\256\241\345\210\222.md" (100%) rename "reports/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" => "docs/reports/transfer/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" (96%) rename "reports/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\351\252\214\346\224\266\351\227\256\351\242\230\344\277\256\345\244\215\346\212\245\345\221\212.md" => "docs/reports/transfer/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\351\252\214\346\224\266\351\227\256\351\242\230\344\277\256\345\244\215\346\212\245\345\221\212.md" (90%) rename "reports/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\344\270\216\344\274\240\350\276\223\344\270\255\345\277\203\344\270\215\345\217\257\347\224\250_\345\216\237\345\233\240\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\350\256\241\345\210\222.md" => "docs/reports/transfer/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\344\270\216\344\274\240\350\276\223\344\270\255\345\277\203\344\270\215\345\217\257\347\224\250_\345\216\237\345\233\240\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\350\256\241\345\210\222.md" (100%) rename "reports/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" => "docs/reports/transfer/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" (91%) rename "reports/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\345\216\237\345\233\240\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\350\256\241\345\210\222.md" => "docs/reports/transfer/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\345\216\237\345\233\240\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\350\256\241\345\210\222.md" (100%) rename "reports/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" => "docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" (96%) rename "reports/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" => "docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" (100%) rename "reports/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" => "docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" (97%) rename "reports/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" => "docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" (100%) rename "reports/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" => "docs/reports/ui/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" (96%) rename "reports/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" => "docs/reports/ui/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" (100%) rename "reports/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" => "docs/reports/ui/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" (97%) rename "reports/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" => "docs/reports/ui/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" (100%) rename "reports/\350\256\276\347\275\256\347\225\214\351\235\242\345\212\250\347\224\273\347\256\200\345\214\226_\350\256\241\345\210\222\344\270\216\345\256\236\346\226\275\346\212\245\345\221\212.md" => "docs/reports/ui/\350\256\276\347\275\256\347\225\214\351\235\242\345\212\250\347\224\273\347\256\200\345\214\226_\350\256\241\345\210\222\344\270\216\345\256\236\346\226\275\346\212\245\345\221\212.md" (100%) diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8947150 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# AGENTS.md + +## Project + +NodeShell is a Wails v2 desktop SSH client: Go backend, React/TypeScript frontend. +`main.go` has two entries: the GUI (default) and a native MCP stdio server (`--mcp`). `internal/app/app.go` is the Wails binding facade; domain logic lives in the other `internal/*` packages. + +Toolchain: Go 1.26, Node 22 (CI), Wails v2.13. The Wails CLI is not an npm dependency. + +## Setup and build order + +- `npm ci && go mod download` +- `npm run build:wails` emits `frontend/dist/`, which `main.go` embeds with `//go:embed all:frontend/dist`. On a clean checkout, `go test .` and `go vet ./...` fail until this exists; `wails build` creates it automatically. `internal/*` packages can be tested without it. +- `npm run dev:wails` only starts Vite on port 5173; use `wails dev` for the desktop app. +- Build with `wails build -skipbindings`. Install the CLI with `go install github.com/wailsapp/wails/v2/cmd/wails@v2.13.0`. +- Ubuntu 24.04/CI has only webkit2gtk 4.1: add `-tags webkit2_41` (e.g. `-platform linux/amd64 -tags webkit2_41`). Windows release/MCP builds use `-windowsconsole` so `--mcp` stdio works under redirected handles; do not drop it. + +## Verify + +- Full check: `npm run typecheck`, `npm test`, `npm run lint`, `npm run build:wails`, `go vet ./...`, then Go tests. +- Single frontend test: `npx vitest run tests/ui/settings.test.tsx`. +- Single Go test: `go test ./internal/mcpcli -run TestName -count=1`. Root-package tests use `go test . -run TestName -count=1` and require `frontend/dist`. +- With `node_modules` present, filter it from Go package lists like CI: `go test $(go list ./... | grep -v /node_modules/)`. CI runs one package at a time with `-timeout 90s`; keep test waits bounded. +- CI keeps `-race` off on every platform (matrix entries set `go-test-race: ''`). Local race runs have a history of hanging on Linux/macOS. + +## Architecture and API contract + +- Root-level `main.go`, `console_*.go`, and `windowsize.go` are the desktop entry package: `//go:embed frontend/dist` is root-relative and the Wails `Bind` call lives there. The App facade and its tests are in `internal/app`; keep Go tests beside their package. +- Keep domain packages (`internal/*` except `internal/app`) free of Wails imports. Services receive dependencies and an `EventSink`; `internal/app/wails_sink.go` adapts events to the runtime. This is what keeps them headless-testable. +- Add a bound API in three places: an exported `` method on `*App` in `internal/app/app.go`, a typed call in `src/renderer/src/api/adapter.ts`, and shared DTOs/event constants in `src/shared/types.ts`. The frontend calls `window.go.app.App` directly. +- App lifecycle is driven by package functions `app.Start`/`app.Stop`; do not turn them into methods, or Wails binds them as frontend-callable APIs. +- `wailsjs/` is generated and gitignored; do not edit or commit it. `frontend/dist/` is build output. +- The frontend intentionally keeps the old `window.api` / `ElectronApi` names even though the runtime is Wails; do not rename the contract. +- Vite root is `src/renderer` and output is `frontend/dist`. Vitest only includes `tests/**/*.test.{ts,tsx}`. +- Go errors crossing IPC implement `ErrorCode() string` using codes from `internal/apperror`; the frontend whitelist is `SSH_CODES` in `src/shared/ipc-error.ts`. Keep both lists in sync, or codes collapse to `UNKNOWN`. +- MCP exposes exactly 10 tools. The count is asserted in `internal/mcpcli/runmcp_test.go` and both `scripts/mcp-smoke.*`; update all of them (and the README) when changing tools. `--mcp` must write protocol only to stdout and nothing to stderr during the smoke test. +- The product is Wails-only: `tests/runtime-dependencies.test.ts` rejects Electron/`ssh2`/MCP SDK dependencies and generic `dev`/`build` scripts. Do not reintroduce them. + +## Security and data invariants + +- SSH host keys are verified before authentication; do not bypass `internal/knownhosts`. +- Passwords and agent API keys live in the OS keyring, not JSON. Never write secrets into `hosts.json` or `settings.json`. +- Local upload/download, private-key, and MCP file paths must pass `internal/localpathguard` and stay under the user home. Remote SFTP paths use POSIX normalization. Do not add permissive path handling. +- Data lives in `os.UserConfigDir()/nodeshell`, intentionally compatible with the Electron 2.0.0 layout. The old `credentials.json` must remain untouched. + +## Testing conventions + +- Vitest defaults to the `node` environment; DOM tests need the `// @vitest-environment jsdom` docblock. +- Use `tests/ui/helpers.tsx` (`installFakeApi`, `renderWithI18n`, event emitters) instead of hardcoding translations or mocking component behavior. +- Go integration tests use the real in-process SSH server in `internal/sshtest`; tests must not depend on the public network or an external SSH daemon. Root-package tests use `testApp` from `app_test.go` with a fake keyring. +- UI tests subscribe through the fake `window.api`; event names must match the Go constants. + +## CI and release + +- `.github/workflows/ci.yml` is the source of truth for packaging flags and platform dependencies; the README is a summary. +- Windows, macOS, and Linux jobs build with Wails and run `scripts/mcp-smoke.*`. Packaging must run on native runners; do not assume local cross-platform Wails builds work. +- `tests/ci-workflow-structure.test.ts` asserts CI structure and version handling; update it when intentionally changing the workflow. +- `package.json` is the version source for CI (`APP_VERSION`). Keep `wails.json` `productVersion` and the README version in sync. Tags matching `v*` produce a draft GitHub release. +- Secrets are mapped to job-level env vars; never reference `secrets.*` in a step-level `if:` (GitHub rejects the workflow). + +## Docs + +`README.md` is the user-facing document (Chinese). `docs/design/wails-rebuild.md` and `docs/reports/` are historical design/acceptance notes, grouped by domain; they are not current authority, so verify claims against code and CI. Start at `docs/README.md` for the index. Put new design notes under `docs/design/` and reports under `docs/reports//`, not at the repository root. diff --git a/README.md b/README.md index f785d66..065cae8 100644 --- a/README.md +++ b/README.md @@ -291,9 +291,9 @@ CI 会在 Windows、macOS 和 Linux 原生 runner 上执行类型检查、前端 ```text . -├── app.go # Wails 应用 facade 与领域服务绑定 -├── main.go # GUI / --mcp 双入口 +├── main.go # GUI / --mcp 双入口与 Wails 窗口启动 ├── internal/ +│ ├── app/ # Wails 应用 facade 与领域服务绑定 │ ├── agent/ # 内置 Agent 服务 │ ├── credentials/ # 凭据与 Keyring 抽象 │ ├── hosts/ # 主机配置存储 @@ -310,7 +310,9 @@ CI 会在 Windows、macOS 和 Linux 原生 runner 上执行类型检查、前端 │ └── ... ├── scripts/ # MCP smoke test 和平台打包脚本 ├── tests/ # 前端和适配层测试 -├── docs/ # 设计与重构文档 +├── docs/ +│ ├── design/ # 重构设计文档 +│ └── reports/ # 历史实施与验收报告(按功能域分类) └── wails.json # Wails 构建配置 ``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d87d899 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,21 @@ +# 文档索引 + +本目录保存 NodeShell 的设计文档与历史实施报告。当前行为以根目录 `README.md`、代码和 `.github/workflows/ci.yml` 为准。 + +## 设计文档 + +- [Wails 重构设计](design/wails-rebuild.md) + +## 历史报告 + +报告按功能域归档,文件名保持不变;报告之间的相互链接已改为相对路径。 + +| 目录 | 内容 | +| --- | --- | +| [reports/agent](reports/agent/) | Agent 供应商、模型与工具调用 | +| [reports/mcp](reports/mcp/) | MCP 权限与外部客户端集成 | +| [reports/terminal](reports/terminal/) | 多会话终端与 xterm.js 优化 | +| [reports/transfer](reports/transfer/) | SFTP、文件传输任务中心与拖放上传 | +| [reports/ui](reports/ui/) | 侧栏、端口映射、设置与主机管理界面 | + +这些报告记录当时的方案、实施过程和验收结果,不代表当前实现;引用前请核对代码。 diff --git a/docs/compose/spec/wails-rebuild.md b/docs/design/wails-rebuild.md similarity index 100% rename from docs/compose/spec/wails-rebuild.md rename to docs/design/wails-rebuild.md diff --git "a/reports/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/reports/agent/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" similarity index 98% rename from "reports/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" rename to "docs/reports/agent/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" index 90591c8..92ff502 100644 --- "a/reports/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" +++ "b/docs/reports/agent/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -1,7 +1,7 @@ # NodeShell Agent 供应商模型 ID 编辑器美化:实施与验收报告 - **报告日期**:2026-08-26 -- **关联计划**:[Agent供应商模型ID编辑器美化_设计与实施计划.md](file:///e:/Projects/NodeShell/reports/Agent%E4%BE%9B%E5%BA%94%E5%95%86%E6%A8%A1%E5%9E%8BID%E7%BC%96%E8%BE%91%E5%99%A8%E7%BE%8E%E5%8C%96_%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E6%96%BD%E8%AE%A1%E5%88%92.md) +- **关联计划**:[Agent供应商模型ID编辑器美化_设计与实施计划.md](Agent供应商模型ID编辑器美化_设计与实施计划.md) - **实施状态**:编辑器核心功能验收通过;整体验收为**有条件通过**(全量测试存在 2 项非编辑器失败,视觉实机项待执行) - **影响范围**:设置页(SettingsModal)-> Agent -> 供应商配置 -> 模型 ID 录入与编辑区域 diff --git "a/reports/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" "b/docs/reports/agent/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" similarity index 100% rename from "reports/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" rename to "docs/reports/agent/Agent\344\276\233\345\272\224\345\225\206\346\250\241\345\236\213ID\347\274\226\350\276\221\345\231\250\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" diff --git "a/reports/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/reports/agent/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" similarity index 91% rename from "reports/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" rename to "docs/reports/agent/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" index f9b60e9..1d6e923 100644 --- "a/reports/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" +++ "b/docs/reports/agent/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -1,7 +1,7 @@ # NodeShell Agent 单次 Prompt 工具调用不限:修复与验收报告 - **报告日期**:2026-08-26 -- **关联计划**:[Agent单次Prompt工具调用不限_实现说明与修改计划.md](file:///e:/Projects/NodeShell/reports/Agent%E5%8D%95%E6%AC%A1Prompt%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8%E4%B8%8D%E9%99%90_%E5%AE%9E%E7%8E%B0%E8%AF%B4%E6%98%8E%E4%B8%8E%E4%BF%AE%E6%94%B9%E8%AE%A1%E5%88%92.md) +- **关联计划**:[Agent单次Prompt工具调用不限_实现说明与修改计划.md](Agent单次Prompt工具调用不限_实现说明与修改计划.md) - **修复状态**:已完成并通过全套前端与后端自动化测试验证(100% PASS) - **影响组件**:后端 Agent 服务循环(`internal/agent`) @@ -24,7 +24,7 @@ Agent stopped after 8 steps without finishing ## 2. 修复方案与代码变更 -### 2.1 取消生产环境 `DefaultMaxTurns = 8` 硬编码限制 ([`internal/agent/agent.go`](file:///e:/Projects/NodeShell/internal/agent/agent.go)) +### 2.1 取消生产环境 `DefaultMaxTurns = 8` 硬编码限制 ([`internal/agent/agent.go`](../../../internal/agent/agent.go)) 1. **移除常量**:彻底删除了 `DefaultMaxTurns = 8`。 2. **重定义 `MaxTurns` 语义**: @@ -50,7 +50,7 @@ Agent stopped after 8 steps without finishing --- -### 2.2 新增与调整后端测试 ([`internal/agent/agent_test.go`](file:///e:/Projects/NodeShell/internal/agent/agent_test.go)) +### 2.2 新增与调整后端测试 ([`internal/agent/agent_test.go`](../../../internal/agent/agent_test.go)) 1. **新增长任务测试 `TestDefaultRunCanExceedEightToolTurnsAndFinish`**: - 构造连续 12 轮工具调用 + 第 13 轮返回最终文本的 SSE 序列; diff --git "a/reports/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\345\256\236\347\216\260\350\257\264\346\230\216\344\270\216\344\277\256\346\224\271\350\256\241\345\210\222.md" "b/docs/reports/agent/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\345\256\236\347\216\260\350\257\264\346\230\216\344\270\216\344\277\256\346\224\271\350\256\241\345\210\222.md" similarity index 100% rename from "reports/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\345\256\236\347\216\260\350\257\264\346\230\216\344\270\216\344\277\256\346\224\271\350\256\241\345\210\222.md" rename to "docs/reports/agent/Agent\345\215\225\346\254\241Prompt\345\267\245\345\205\267\350\260\203\347\224\250\344\270\215\351\231\220_\345\256\236\347\216\260\350\257\264\346\230\216\344\270\216\344\277\256\346\224\271\350\256\241\345\210\222.md" diff --git "a/reports/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/reports/mcp/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" similarity index 98% rename from "reports/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" rename to "docs/reports/mcp/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" index 4b92bec..2360142 100644 --- "a/reports/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" +++ "b/docs/reports/mcp/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -98,7 +98,7 @@ GUI 与 MCP 是两个进程。MCP 不读取 GUI 内存中的 allow-session;loc | `src/renderer/src/components/SettingsModal.tsx` | MCP 页独立选项;GUI 权限文案拆开 | | `src/renderer/src/i18n/locales/{en,zh}.json` | 中英文案 | | `tests/ui/settings.test.tsx` | 默认 external、切换 local、GUI 权限文案 | -| `reports/MCP权限外置化_实施与验收报告.md` | 本报告 | +| `docs/reports/mcp/MCP权限外置化_实施与验收报告.md` | 本报告 | 未改 P2 终端相关未提交文件。 diff --git "a/reports/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" "b/docs/reports/mcp/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" similarity index 100% rename from "reports/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" rename to "docs/reports/mcp/MCP\346\235\203\351\231\220\345\244\226\347\275\256\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" diff --git "a/reports/xterm.js_P0\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/reports/terminal/xterm.js_P0\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" similarity index 86% rename from "reports/xterm.js_P0\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" rename to "docs/reports/terminal/xterm.js_P0\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" index 04d9128..8cddffa 100644 --- "a/reports/xterm.js_P0\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" +++ "b/docs/reports/terminal/xterm.js_P0\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -1,7 +1,7 @@ # xterm.js P0 稳定性与性能优化:实施与验收报告 - **实施日期**:2026-08-30 -- **关联计划**:[xterm.js_P0优化_编程Agent实施计划.md](file:///E:/Projects/NodeShell/reports/xterm.js_P0优化_编程Agent实施计划.md) +- **关联计划**:[xterm.js_P0优化_编程Agent实施计划.md](xterm.js_P0优化_编程Agent实施计划.md) - **状态**:**正式验收全部通过 (PASS)** --- @@ -11,7 +11,7 @@ 针对最新代码评审提出的晚连接路径 worker 泄漏防范、有界队列背压控制与文档/测试规范,完成了全面闭环: ### 1.1 晚连接路径 Batcher Worker 泄漏修复 (P1 修复) -- 在 [`internal/sessions/sessions.go`](file:///E:/Projects/NodeShell/internal/sessions/sessions.go) 的 `Connect()` 方法中,当在连接握手完成后检测到 `m.closing == true`(即应用已执行 `DisposeAll` 正在退出)时: +- 在 [`internal/sessions/sessions.go`](../../../internal/sessions/sessions.go) 的 `Connect()` 方法中,当在连接握手完成后检测到 `m.closing == true`(即应用已执行 `DisposeAll` 正在退出)时: ```go m.mu.Lock() if m.closing { @@ -26,7 +26,7 @@ - **测试覆盖**:在 `internal/sessions/sessions_test.go` 中新增 `TestConnectDuringDisposeAllWorkerExits`,验证在 DisposeAll 竞态下返回 Cancelled 错误且 batcher worker 正常退出。 ### 1.2 有界背压队列与内存上限控制 (P1/P2 优化) -- 在 [`internal/sessions/batcher.go`](file:///E:/Projects/NodeShell/internal/sessions/batcher.go) 中引入常量 `maxQueueBatches = 64`(单会话最大缓冲约 3MB 批次): +- 在 [`internal/sessions/batcher.go`](../../../internal/sessions/batcher.go) 中引入常量 `maxQueueBatches = 64`(单会话最大缓冲约 3MB 批次): - 当高速输出(如 `cat` 大文件)导致 `queue` 达到容量上限时,`Add()` 阻塞在 `b.cond.Wait()` 上; - 从而自然暂停 SSH 输出泵(`conn.Stdout().Read()`),通过 TCP 窗口滑动反压向远程服务端限速,彻底防止前端慢消费场景下的内存无界膨胀; - 当 worker 消费批次或执行 `Close()` / `Discard()` 时广播 `b.cond.Broadcast()`,解除阻塞; diff --git "a/reports/xterm.js_P0\344\274\230\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" "b/docs/reports/terminal/xterm.js_P0\344\274\230\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" similarity index 100% rename from "reports/xterm.js_P0\344\274\230\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" rename to "docs/reports/terminal/xterm.js_P0\344\274\230\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" diff --git "a/reports/xterm.js_P0\347\250\263\345\256\232\346\200\247\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226\350\256\241\345\210\222.md" "b/docs/reports/terminal/xterm.js_P0\347\250\263\345\256\232\346\200\247\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226\350\256\241\345\210\222.md" similarity index 100% rename from "reports/xterm.js_P0\347\250\263\345\256\232\346\200\247\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226\350\256\241\345\210\222.md" rename to "docs/reports/terminal/xterm.js_P0\347\250\263\345\256\232\346\200\247\344\270\216\346\200\247\350\203\275\344\274\230\345\214\226\350\256\241\345\210\222.md" diff --git "a/reports/xterm.js_P2\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/reports/terminal/xterm.js_P2\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" similarity index 97% rename from "reports/xterm.js_P2\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" rename to "docs/reports/terminal/xterm.js_P2\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" index 1a572a7..8e6b6dc 100644 --- "a/reports/xterm.js_P2\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" +++ "b/docs/reports/terminal/xterm.js_P2\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -83,8 +83,8 @@ go test -bench=BenchmarkBatcherSustainedHighRate -benchtime=5m ./internal/sessio | `tests/ui/terminal-stress.ts` | 新增 | 前端压力语料 | | `tests/ui/terminal-view-resize.test.tsx` | 修改 | 大块分帧、WebGL 不重试、后台写入、8 会话、指标 | | `tests/ui/session-terminal-persistence.test.tsx` | 修改 | 重连替换实例、重复关闭幂等 | -| `reports/xterm.js_P2优化_性能基线.md` | 新增 | 基准数字 | -| `reports/xterm.js_P2优化_实施与验收报告.md` | 新增 | 本报告 | +| `docs/reports/terminal/xterm.js_P2优化_性能基线.md` | 新增 | 基准数字 | +| `docs/reports/terminal/xterm.js_P2优化_实施与验收报告.md` | 新增 | 本报告 | 未修改 SSH/PTY 类型、`sessions.write` / `sessions.resize` / `session:data` 契约、SFTP、Agent、端口映射。 diff --git "a/reports/xterm.js_P2\344\274\230\345\214\226_\346\200\247\350\203\275\345\237\272\347\272\277.md" "b/docs/reports/terminal/xterm.js_P2\344\274\230\345\214\226_\346\200\247\350\203\275\345\237\272\347\272\277.md" similarity index 100% rename from "reports/xterm.js_P2\344\274\230\345\214\226_\346\200\247\350\203\275\345\237\272\347\272\277.md" rename to "docs/reports/terminal/xterm.js_P2\344\274\230\345\214\226_\346\200\247\350\203\275\345\237\272\347\272\277.md" diff --git "a/reports/xterm.js_P2\344\274\230\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" "b/docs/reports/terminal/xterm.js_P2\344\274\230\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" similarity index 100% rename from "reports/xterm.js_P2\344\274\230\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" rename to "docs/reports/terminal/xterm.js_P2\344\274\230\345\214\226_\347\274\226\347\250\213Agent\345\256\236\346\226\275\350\256\241\345\210\222.md" diff --git "a/reports/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_bug\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\345\273\272\350\256\256.md" "b/docs/reports/terminal/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_bug\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\345\273\272\350\256\256.md" similarity index 100% rename from "reports/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_bug\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\345\273\272\350\256\256.md" rename to "docs/reports/terminal/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_bug\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\345\273\272\350\256\256.md" diff --git "a/reports/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/reports/terminal/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" similarity index 98% rename from "reports/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" rename to "docs/reports/terminal/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" index 33db0e6..70a2490 100644 --- "a/reports/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" +++ "b/docs/reports/terminal/\345\244\232\344\274\232\350\257\235\345\210\207\346\215\242\347\273\210\347\253\257\346\270\205\345\261\217_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -1,7 +1,7 @@ # NodeShell 多会话切换终端清屏:修复与验收报告 - **报告日期**:2026-08-26 -- **关联问题**:多会话切换导致终端清屏与状态丢失([多会话切换终端清屏_bug定位与修复建议.md](file:///e:/Projects/NodeShell/reports/%E5%A4%9A%E4%BC%9A%E8%AF%9D%E5%88%87%E6%8D%A2%E7%BB%88%E7%AB%AF%E6%B8%85%E5%B1%8F_bug%E5%AE%9A%E4%BD%8D%E4%B8%8E%E4%BF%AE%E5%A4%8D%E5%BB%BA%E8%AE%AE.md)) +- **关联问题**:多会话切换导致终端清屏与状态丢失([多会话切换终端清屏_bug定位与修复建议.md](多会话切换终端清屏_bug定位与修复建议.md)) - **修复状态**:已完成并通过全套前端与后端自动化测试验证(100% PASS) - **影响组件**:前端 React 会话标签与终端视图渲染层、SSH 客户端测试套件 diff --git "a/reports/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\214\346\225\264\344\274\230\345\214\226\345\256\236\346\226\275\350\256\241\345\210\222.md" "b/docs/reports/transfer/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\214\346\225\264\344\274\230\345\214\226\345\256\236\346\226\275\350\256\241\345\210\222.md" similarity index 100% rename from "reports/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\214\346\225\264\344\274\230\345\214\226\345\256\236\346\226\275\350\256\241\345\210\222.md" rename to "docs/reports/transfer/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\214\346\225\264\344\274\230\345\214\226\345\256\236\346\226\275\350\256\241\345\210\222.md" diff --git "a/reports/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/reports/transfer/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" similarity index 96% rename from "reports/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" rename to "docs/reports/transfer/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" index 25b5894..e2ecf71 100644 --- "a/reports/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" +++ "b/docs/reports/transfer/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -2,9 +2,9 @@ - **报告日期**:2026-08-26 - **关联计划**: - - [文件传输任务中心_完整优化实施计划.md](file:///e:/Projects/NodeShell/reports/文件传输任务中心_完整优化实施计划.md) - - [文件拖放上传与传输中心不可用_原因定位与修复计划.md](file:///e:/Projects/NodeShell/reports/文件拖放上传与传输中心不可用_原因定位与修复计划.md) - - [文件传输任务中心_验收问题修复报告.md](file:///e:/Projects/NodeShell/reports/文件传输任务中心_验收问题修复报告.md) + - [文件传输任务中心_完整优化实施计划.md](文件传输任务中心_完整优化实施计划.md) + - [文件拖放上传与传输中心不可用_原因定位与修复计划.md](文件拖放上传与传输中心不可用_原因定位与修复计划.md) + - [文件传输任务中心_验收问题修复报告.md](文件传输任务中心_验收问题修复报告.md) - **实施状态**:**全部通过 (PASS)** - **影响范围**: - 后端:`internal/sftpservice`(`TransferManager` 调度状态机、事件顺序保障、`UploadWithContext`/`DownloadWithContext` 提交前中断检测与 CWD 解耦、`sync.WaitGroup` 优雅关闭)、`internal/sessions`(会话元数据解析)、`app.go`(Wails 绑定、绝对远端路径解析与生命周期) diff --git "a/reports/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\351\252\214\346\224\266\351\227\256\351\242\230\344\277\256\345\244\215\346\212\245\345\221\212.md" "b/docs/reports/transfer/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\351\252\214\346\224\266\351\227\256\351\242\230\344\277\256\345\244\215\346\212\245\345\221\212.md" similarity index 90% rename from "reports/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\351\252\214\346\224\266\351\227\256\351\242\230\344\277\256\345\244\215\346\212\245\345\221\212.md" rename to "docs/reports/transfer/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\351\252\214\346\224\266\351\227\256\351\242\230\344\277\256\345\244\215\346\212\245\345\221\212.md" index 3f48497..1687d1d 100644 --- "a/reports/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\351\252\214\346\224\266\351\227\256\351\242\230\344\277\256\345\244\215\346\212\245\345\221\212.md" +++ "b/docs/reports/transfer/\346\226\207\344\273\266\344\274\240\350\276\223\344\273\273\345\212\241\344\270\255\345\277\203_\351\252\214\346\224\266\351\227\256\351\242\230\344\277\256\345\244\215\346\212\245\345\221\212.md" @@ -34,10 +34,10 @@ ### 3.1 问题位置 -- [internal/sftpservice/transfer_manager.go:238](E:/Projects/NodeShell/internal/sftpservice/transfer_manager.go:238) -- [internal/sftpservice/transfer_manager.go:241](E:/Projects/NodeShell/internal/sftpservice/transfer_manager.go:241) -- [internal/sftpservice/transfer_manager.go:462](E:/Projects/NodeShell/internal/sftpservice/transfer_manager.go:462) -- [src/renderer/src/hooks/useTransferTasks.ts:73](E:/Projects/NodeShell/src/renderer/src/hooks/useTransferTasks.ts:73) +- [internal/sftpservice/transfer_manager.go:238](../../../internal/sftpservice/transfer_manager.go) +- [internal/sftpservice/transfer_manager.go:241](../../../internal/sftpservice/transfer_manager.go) +- [internal/sftpservice/transfer_manager.go:462](../../../internal/sftpservice/transfer_manager.go) +- [src/renderer/src/hooks/useTransferTasks.ts:73](../../../src/renderer/src/hooks/useTransferTasks.ts) ### 3.2 根因 @@ -121,9 +121,9 @@ running -> succeeded -> queued ### 4.1 问题位置 -- [src/renderer/src/components/SftpPanel.tsx:262-269](E:/Projects/NodeShell/src/renderer/src/components/SftpPanel.tsx:262) -- [internal/sftpservice/transfer_manager.go:249-288](E:/Projects/NodeShell/internal/sftpservice/transfer_manager.go:249) -- [internal/sftpservice/sftpservice.go:1129](E:/Projects/NodeShell/internal/sftpservice/sftpservice.go:1129) +- [src/renderer/src/components/SftpPanel.tsx:262-269](../../../src/renderer/src/components/SftpPanel.tsx) +- [internal/sftpservice/transfer_manager.go:249-288](../../../internal/sftpservice/transfer_manager.go) +- [internal/sftpservice/sftpservice.go:1129](../../../internal/sftpservice/sftpservice.go) ### 4.2 根因 @@ -188,10 +188,10 @@ type Task struct { ### 5.1 问题位置 -- [src/renderer/src/components/TransferCenter.tsx:277-293](E:/Projects/NodeShell/src/renderer/src/components/TransferCenter.tsx:277) -- [internal/sftpservice/sftpservice.go:1096-1104](E:/Projects/NodeShell/internal/sftpservice/sftpservice.go:1096) -- [internal/sftpservice/sftpservice.go:1210-1222](E:/Projects/NodeShell/internal/sftpservice/sftpservice.go:1210) -- [internal/sftpservice/transfer_manager.go:503-513](E:/Projects/NodeShell/internal/sftpservice/transfer_manager.go:503) +- [src/renderer/src/components/TransferCenter.tsx:277-293](../../../src/renderer/src/components/TransferCenter.tsx) +- [internal/sftpservice/sftpservice.go:1096-1104](../../../internal/sftpservice/sftpservice.go) +- [internal/sftpservice/sftpservice.go:1210-1222](../../../internal/sftpservice/sftpservice.go) +- [internal/sftpservice/transfer_manager.go:503-513](../../../internal/sftpservice/transfer_manager.go) ### 5.2 根因 @@ -269,8 +269,8 @@ if runErr == nil { ### 6.1 问题位置 -- [internal/sftpservice/transfer_manager.go:399-426](E:/Projects/NodeShell/internal/sftpservice/transfer_manager.go:399) -- [app.go:377-403](E:/Projects/NodeShell/app.go:377) +- [internal/sftpservice/transfer_manager.go:399-426](../../../internal/sftpservice/transfer_manager.go) +- [app.go:377-403](../../../internal/app/app.go) ### 6.2 根因 diff --git "a/reports/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\344\270\216\344\274\240\350\276\223\344\270\255\345\277\203\344\270\215\345\217\257\347\224\250_\345\216\237\345\233\240\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\350\256\241\345\210\222.md" "b/docs/reports/transfer/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\344\270\216\344\274\240\350\276\223\344\270\255\345\277\203\344\270\215\345\217\257\347\224\250_\345\216\237\345\233\240\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\350\256\241\345\210\222.md" similarity index 100% rename from "reports/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\344\270\216\344\274\240\350\276\223\344\270\255\345\277\203\344\270\215\345\217\257\347\224\250_\345\216\237\345\233\240\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\350\256\241\345\210\222.md" rename to "docs/reports/transfer/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\344\270\216\344\274\240\350\276\223\344\270\255\345\277\203\344\270\215\345\217\257\347\224\250_\345\216\237\345\233\240\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\350\256\241\345\210\222.md" diff --git "a/reports/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/reports/transfer/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" similarity index 91% rename from "reports/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" rename to "docs/reports/transfer/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" index 681af6a..73472aa 100644 --- "a/reports/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" +++ "b/docs/reports/transfer/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\344\277\256\345\244\215\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -1,7 +1,7 @@ # NodeShell 文件拖放上传失效:修复与验收报告 - **报告日期**:2026-08-26 -- **关联计划**:[文件拖放上传失效_原因定位与修复计划.md](file:///e:/Projects/NodeShell/reports/%E6%96%87%E4%BB%B6%E6%8B%96%E6%94%BE%E4%B8%8A%E4%BC%A0%E5%A4%B1%E6%95%88_%E5%8E%9F%E5%9B%A0%E5%AE%9A%E4%BD%8D%E4%B8%8E%E4%BF%AE%E5%A4%8D%E8%AE%A1%E5%88%92.md) +- **关联计划**:[文件拖放上传失效_原因定位与修复计划.md](文件拖放上传失效_原因定位与修复计划.md) - **修复状态**:已完成并通过全套前端与后端自动化测试验证(100% PASS) - **影响组件**:前端 Bridge/Adapter 原生文件拖放注册、SFTP 面板、Go App 启动中转 @@ -101,16 +101,16 @@ export class WailsBridge implements ApiBridge { ## 3. 自动化测试验证 ### 3.1 测试清单与覆盖 -1. **Bridge 层测试** ([`tests/api-adapter.test.ts`](file:///e:/Projects/NodeShell/tests/api-adapter.test.ts)): +1. **Bridge 层测试** ([`tests/api-adapter.test.ts`](../../../tests/api-adapter.test.ts)): - 验证 `WailsBridge.onFileDrop` 正确向 `window.runtime.OnFileDrop` 注册 `useDropTarget = true`; - 验证 Wails 回调给出的 `(x, y, paths)` 中坐标被过滤、`paths` 成功透传给业务回调; - 验证空 paths 不触发回调; - 验证返回的 unsubscribe 触发 `OnFileDropOff` 且具备幂等性; - 验证无 `window.runtime.OnFileDrop` 环境返回 `undefined`。 -2. **Adapter 层测试** ([`tests/api-adapter.test.ts`](file:///e:/Projects/NodeShell/tests/api-adapter.test.ts)): +2. **Adapter 层测试** ([`tests/api-adapter.test.ts`](../../../tests/api-adapter.test.ts)): - 验证 `api.files.onDrop` 成功委托给 `bridge.onFileDrop`; - 验证当 bridge 未提供 `onFileDrop` 时,`api.files.onDrop` 为 `undefined`。 -3. **SFTP 面板测试** ([`tests/ui/sftp.test.tsx`](file:///e:/Projects/NodeShell/tests/ui/sftp.test.tsx)): +3. **SFTP 面板测试** ([`tests/ui/sftp.test.tsx`](../../../tests/ui/sftp.test.tsx)): - 验证原生 drop 回调接收到文件绝对路径后调用当前会话的 `uploadPaths`; - 验证原生 drop 与 DOM drop 不会产生重复上传; - 验证原生能力不可用时自动回退走 DOM `getPathForFile`; diff --git "a/reports/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\345\216\237\345\233\240\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\350\256\241\345\210\222.md" "b/docs/reports/transfer/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\345\216\237\345\233\240\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\350\256\241\345\210\222.md" similarity index 100% rename from "reports/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\345\216\237\345\233\240\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\350\256\241\345\210\222.md" rename to "docs/reports/transfer/\346\226\207\344\273\266\346\213\226\346\224\276\344\270\212\344\274\240\345\244\261\346\225\210_\345\216\237\345\233\240\345\256\232\344\275\215\344\270\216\344\277\256\345\244\215\350\256\241\345\210\222.md" diff --git "a/reports/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" similarity index 96% rename from "reports/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" rename to "docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" index 3451dfa..6c58b29 100644 --- "a/reports/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" +++ "b/docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -1,7 +1,7 @@ # 主机管理弹窗空状态美化:实施与验收报告 - **实施日期**:2026-08-26 -- **关联计划**:[主机管理弹窗空状态美化_设计与实施计划.md](file:///e:/Projects/NodeShell/reports/主机管理弹窗空状态美化_设计与实施计划.md) +- **关联计划**:[主机管理弹窗空状态美化_设计与实施计划.md](主机管理弹窗空状态美化_设计与实施计划.md) - **状态**:**已完成并全部通过 (PASS)** --- diff --git "a/reports/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" "b/docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" similarity index 100% rename from "reports/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" rename to "docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\345\274\271\347\252\227\347\251\272\347\212\266\346\200\201\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" diff --git "a/reports/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" similarity index 97% rename from "reports/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" rename to "docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" index f6d5c3c..9ca4141 100644 --- "a/reports/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" +++ "b/docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -1,7 +1,7 @@ # 主机管理无分页布局与美化:实施与验收报告 - **实施日期**:2026-08-26 -- **关联计划**:[主机管理无分页布局与美化_设计与实施计划.md](file:///e:/Projects/NodeShell/reports/主机管理无分页布局与美化_设计与实施计划.md) +- **关联计划**:[主机管理无分页布局与美化_设计与实施计划.md](主机管理无分页布局与美化_设计与实施计划.md) - **状态**:**已完成并全部通过 (PASS)** --- diff --git "a/reports/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" "b/docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" similarity index 100% rename from "reports/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" rename to "docs/reports/ui/\344\270\273\346\234\272\347\256\241\347\220\206\346\227\240\345\210\206\351\241\265\345\270\203\345\261\200\344\270\216\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" diff --git "a/reports/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/reports/ui/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" similarity index 96% rename from "reports/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" rename to "docs/reports/ui/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" index d1446b7..50c64c1 100644 --- "a/reports/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" +++ "b/docs/reports/ui/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -1,7 +1,7 @@ # 侧栏展开收起动画优化:实施与验收报告 - **实施日期**:2026-08-26 -- **关联计划**:[侧栏展开收起动画优化_设计与实施计划.md](file:///e:/Projects/NodeShell/reports/侧栏展开收起动画优化_设计与实施计划.md) +- **关联计划**:[侧栏展开收起动画优化_设计与实施计划.md](侧栏展开收起动画优化_设计与实施计划.md) - **状态**:**已完成并全部通过 (PASS)** --- diff --git "a/reports/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" "b/docs/reports/ui/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" similarity index 100% rename from "reports/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" rename to "docs/reports/ui/\344\276\247\346\240\217\345\261\225\345\274\200\346\224\266\350\265\267\345\212\250\347\224\273\344\274\230\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" diff --git "a/reports/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" "b/docs/reports/ui/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" similarity index 97% rename from "reports/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" rename to "docs/reports/ui/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" index 938cb58..1783632 100644 --- "a/reports/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" +++ "b/docs/reports/ui/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\345\256\236\346\226\275\344\270\216\351\252\214\346\224\266\346\212\245\345\221\212.md" @@ -1,7 +1,7 @@ # 端口映射栏美化:实施与验收报告 - **实施日期**:2026-08-26 -- **关联计划**:[端口映射栏美化_设计与实施计划.md](file:///e:/Projects/NodeShell/reports/端口映射栏美化_设计与实施计划.md) +- **关联计划**:[端口映射栏美化_设计与实施计划.md](端口映射栏美化_设计与实施计划.md) - **状态**:**已完成并全部通过 (PASS)** --- diff --git "a/reports/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" "b/docs/reports/ui/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" similarity index 100% rename from "reports/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" rename to "docs/reports/ui/\347\253\257\345\217\243\346\230\240\345\260\204\346\240\217\347\276\216\345\214\226_\350\256\276\350\256\241\344\270\216\345\256\236\346\226\275\350\256\241\345\210\222.md" diff --git "a/reports/\350\256\276\347\275\256\347\225\214\351\235\242\345\212\250\347\224\273\347\256\200\345\214\226_\350\256\241\345\210\222\344\270\216\345\256\236\346\226\275\346\212\245\345\221\212.md" "b/docs/reports/ui/\350\256\276\347\275\256\347\225\214\351\235\242\345\212\250\347\224\273\347\256\200\345\214\226_\350\256\241\345\210\222\344\270\216\345\256\236\346\226\275\346\212\245\345\221\212.md" similarity index 100% rename from "reports/\350\256\276\347\275\256\347\225\214\351\235\242\345\212\250\347\224\273\347\256\200\345\214\226_\350\256\241\345\210\222\344\270\216\345\256\236\346\226\275\346\212\245\345\221\212.md" rename to "docs/reports/ui/\350\256\276\347\275\256\347\225\214\351\235\242\345\212\250\347\224\273\347\256\200\345\214\226_\350\256\241\345\210\222\344\270\216\345\256\236\346\226\275\346\212\245\345\221\212.md" From 0d13eb0e2d0bd98833860538a1cf133ec273dc35 Mon Sep 17 00:00:00 2001 From: AzureLandin Date: Wed, 21 Oct 2026 15:06:55 +0800 Subject: [PATCH 3/5] feat: replace agent loop with Eino ChatModelAgent Add Eino model factory, runner, tool adapters, message conversion and error mapping. Keep NodeShell transcript ownership and BoundCaller security boundary. --- go.mod | 29 + go.sum | 128 +++++ internal/agent/agent.go | 122 ++--- internal/agent/agent_test.go | 979 ++++++++++++++++++++++++---------- internal/agent/convert.go | 159 ++++++ internal/agent/eino_delta.go | 70 +++ internal/agent/eino_runner.go | 213 ++++++++ internal/agent/eino_tools.go | 153 ++++++ internal/agent/errors.go | 80 +++ internal/agent/llm.go | 320 ----------- internal/agent/model.go | 26 + internal/agent/tools.go | 97 +--- 12 files changed, 1621 insertions(+), 755 deletions(-) create mode 100644 internal/agent/convert.go create mode 100644 internal/agent/eino_delta.go create mode 100644 internal/agent/eino_runner.go create mode 100644 internal/agent/eino_tools.go create mode 100644 internal/agent/errors.go delete mode 100644 internal/agent/llm.go create mode 100644 internal/agent/model.go diff --git a/go.mod b/go.mod index 58a0123..06c6ea5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module nodeshell go 1.26 require ( + github.com/cloudwego/eino v0.9.22 + github.com/cloudwego/eino-ext/components/model/openai v0.1.13 github.com/google/uuid v1.6.0 github.com/pkg/sftp v1.13.11 github.com/wailsapp/wails/v2 v2.13.0 @@ -13,12 +15,25 @@ require ( require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/bep/debounce v1.2.1 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/eino-contrib/jsonschema v1.0.3 // indirect + github.com/evanphx/json-patch v0.5.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/goph/emperror v0.17.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/gommon v0.4.2 // indirect @@ -26,19 +41,33 @@ require ( github.com/leaanthony/gosod v1.0.4 // indirect github.com/leaanthony/slicer v1.6.0 // indirect github.com/leaanthony/u v1.1.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/meguminnnnnnnnn/go-openai v0.1.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/nikolalohinski/gonja v1.5.3 // indirect + github.com/pelletier/go-toml/v2 v2.0.9 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.49.1 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/yargevad/filepathx v1.0.0 // indirect + golang.org/x/arch v0.11.0 // indirect + golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect golang.org/x/net v0.56.0 // indirect golang.org/x/text v0.40.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) replace golang.org/x/sys => golang.org/x/sys v0.44.0 diff --git a/go.sum b/go.sum index debbc9c..e27502f 100644 --- a/go.sum +++ b/go.sum @@ -1,23 +1,84 @@ git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= +github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0= +github.com/bytedance/mockey v1.3.0/go.mod h1:1BPHF9sol5R1ud/+0VEHGQq/+i2lN+GTsr3O2Q9IENY= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cloudwego/eino v0.9.22 h1:qBSgj+H5geYTysjta/bgdE9OVXX+9MVXP74vA6QA4GQ= +github.com/cloudwego/eino v0.9.22/go.mod h1:OBD1mrkfkt/pJa4rkg1P0VnaMeOVl7l8IAdEqY//3IQ= +github.com/cloudwego/eino-ext/components/model/openai v0.1.13 h1:5XHRTiTD5bt9KQrMHcfvuWNklEC3tpm3XHejdozt9vM= +github.com/cloudwego/eino-ext/components/model/openai v0.1.13/go.mod h1:mgIoqYYOc0eECCqvLbEYpOJrQNTNxkwXzSJzFU+v5sQ= +github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17 h1:EeVcR1TslRA2IdNW1h/2LaGbPlffwGhQm99jM3zWZiI= +github.com/cloudwego/eino-ext/libs/acl/openai v0.1.17/go.mod h1:Zkcx6DPTR2NfWmtSXbhItswGw6hqUezNPhNcke0pOG8= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= +github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= +github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= +github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= +github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= +github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= +github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= +github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -32,6 +93,8 @@ github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/ github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= @@ -40,8 +103,27 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/meguminnnnnnnnn/go-openai v0.1.2 h1:iXombGGjqjBrmE9WaSidUhhi3YQhf42QTHvHLMkgvCA= +github.com/meguminnnnnnnnn/go-openai v0.1.2/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= +github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0= +github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= @@ -51,14 +133,37 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f h1:Z2cODYsUxQPofhpYRMQVwWz4yUVpHF+vPi+eUdruUYI= +github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f/go.mod h1:JqzWyvTuI2X4+9wOHmKSQCYxybB/8j6Ko43qVmXDuZg= +github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= +github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= +github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= +github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= @@ -69,21 +174,44 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v2Mg= github.com/wailsapp/wails/v2 v2.13.0/go.mod h1:nVr/wSIEZ7xxKPkzK65mjpKpaOPQI2k4pvLwGR/i4kc= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= +github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= +github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= +github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= +go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= +go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= +golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= +golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= +golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 774cb09..e13b7f1 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -1,9 +1,9 @@ // Package agent runs a small tool-calling assistant against one already // connected SSH session. It owns no transport of its own: the LLM is reached -// over an OpenAI-compatible HTTP endpoint, and every tool the model may call -// is served through BoundCaller → mcpcli.Runtime.Call, with the current -// session injected so the assistant can only ever touch the remote host the -// user is already looking at. +// over an OpenAI-compatible HTTP endpoint through the Eino ADK, and every tool +// the model may call is served through BoundCaller → mcpcli.Runtime.Call, with +// the current session injected so the assistant can only ever touch the remote +// host the user is already looking at. // // The package deliberately implements only the loop (prompt -> stream -> // tools -> stream) and none of the extras a full coding agent ships: no local @@ -20,6 +20,8 @@ import ( "sync" "time" + "github.com/cloudwego/eino/components/model" + "nodeshell/internal/apperror" "nodeshell/internal/mcpcli" ) @@ -132,12 +134,20 @@ type ErrorEvent struct { } // Deps wires a Service. MaxTurns > 0 sets an explicit assistant turn limit -// (used in tests); zero (the default) means unlimited turns. Zero timeouts -// fall back to the package defaults. +// (used in tests); zero (the default) means the Eino default budget of 20 +// iterations. Zero timeouts fall back to the package defaults. type Deps struct { - Tools ToolCaller - Sink EventSink - Client *http.Client + Tools ToolCaller + Sink EventSink + // Client is the HTTP client the provider requests go through. A nil + // client means a plain http.Client without a timeout of its own; the + // request timeout is applied per request instead. + Client *http.Client + // Model builds the chat model for one run. It is the seam the tests use: + // a factory that returns a scripted model keeps every test off the + // network. A nil factory means the OpenAI-compatible model built from + // the run's Config. + Model ModelFactory MaxTurns int ExecTimeout time.Duration RequestTimeout time.Duration @@ -164,6 +174,7 @@ type Service struct { tools ToolCaller sink EventSink client *http.Client + model ModelFactory maxTurns int execTimeout time.Duration requestTimeout time.Duration @@ -181,7 +192,7 @@ func New(d Deps) *Service { d.Sink = nopSink{} } if d.Client == nil { - // No client-level timeout: each request is bounded by its own context + // No client-level timeout: each request is bounded by its own budget // so a long stream is not cut off mid-answer. d.Client = &http.Client{} } @@ -194,7 +205,8 @@ func New(d Deps) *Service { if d.RequestTimeout <= 0 { d.RequestTimeout = DefaultRequestTimeout } - return &Service{ + factory := d.Model + svc := &Service{ tools: d.Tools, sink: d.Sink, client: d.Client, @@ -203,6 +215,23 @@ func New(d Deps) *Service { requestTimeout: d.RequestTimeout, convs: make(map[string]*conversation), } + if factory != nil { + svc.model = factory + } else { + svc.model = svc.openAIModel + } + return svc +} + +// openAIModel builds the provider model for one run. The request timeout +// bounds one provider request, stream included, so it belongs on the client +// of that request. +func (s *Service) openAIModel(ctx context.Context, cfg Config) (model.ToolCallingChatModel, error) { + client := *s.client + if client.Timeout == 0 { + client.Timeout = s.requestTimeout + } + return newOpenAIModel(ctx, cfg, &client) } // Prompt accepts one user message for the session and starts a run in the @@ -359,50 +388,14 @@ func (s *Service) run(ctx context.Context, cancel context.CancelFunc, sessionID, s.emitIfCurrent(sessionID, c, EventDone, DoneEvent{SessionID: sessionID, Aborted: aborted}) } -// loop alternates streamed assistant turns and tool execution until the model -// answers without tool calls. When maxTurns > 0, reaching the turn limit is -// an observable error, never a silent stop. +// loop hands the prompt to the Eino agent and folds whatever it produced +// back into the NodeShell transcript. The transcript stays the source of +// truth: it is replayed to the agent, and the agent's assistant and tool +// messages are appended to it afterwards. func (s *Service) loop(ctx context.Context, sessionID, title string, cfg Config, c *conversation) error { - for turn := 0; ; turn++ { - if s.maxTurns > 0 && turn >= s.maxTurns { - return errf(apperror.Unknown, "Agent stopped after %d steps without finishing", s.maxTurns) - } - if err := ctx.Err(); err != nil { - return err - } - msgs := s.snapshot(c, title) - reqCtx, cancelReq := context.WithTimeout(ctx, s.requestTimeout) - res, err := s.stream(reqCtx, cfg, msgs, func(delta string) { - s.emitIfCurrent(sessionID, c, EventDelta, DeltaEvent{SessionID: sessionID, Delta: delta}) - }) - cancelReq() - if err != nil { - return err - } - s.append(c, assistantMessage(res)) - if len(res.toolCalls) == 0 { - return nil - } - for _, call := range res.toolCalls { - if err := ctx.Err(); err != nil { - return err - } - out, summary, ok := s.runTool(ctx, sessionID, title, call) - detail := "" - if !ok { - detail = firstLine(out) - } - s.emitIfCurrent(sessionID, c, EventTool, ToolEvent{ - SessionID: sessionID, - CallID: call.ID, - Name: call.Function.Name, - Summary: summary, - OK: ok, - Detail: detail, - }) - s.append(c, chatMessage{Role: roleTool, ToolCallID: call.ID, Content: out}) - } - } + produced, err := s.runEino(ctx, sessionID, title, cfg, c) + s.appendAll(c, produced) + return err } // emitIfCurrent drops events for a run that has been Clear'd, Dispose'd or @@ -418,21 +411,24 @@ func (s *Service) emitIfCurrent(sessionID string, c *conversation, event string, } } -// snapshot returns the system prompt followed by a copy of the transcript, so -// the request is built from a stable view even if the conversation is cleared -// while the request is in flight. -func (s *Service) snapshot(c *conversation, title string) []chatMessage { +// snapshot returns a copy of the transcript, so the request is built from a +// stable view even if the conversation is cleared while the request is in +// flight. The system prompt is not part of it: the Eino agent carries it as +// its instruction. +func (s *Service) snapshot(c *conversation) []chatMessage { s.mu.Lock() defer s.mu.Unlock() - msgs := make([]chatMessage, 0, len(c.messages)+1) - msgs = append(msgs, chatMessage{Role: roleSystem, Content: systemPrompt(title)}) - return append(msgs, c.messages...) + return append([]chatMessage(nil), c.messages...) } -func (s *Service) append(c *conversation, msg chatMessage) { +// appendAll folds the messages of one run back into the transcript. +func (s *Service) appendAll(c *conversation, msgs []chatMessage) { + if len(msgs) == 0 { + return + } s.mu.Lock() defer s.mu.Unlock() - c.messages = trimHistory(append(c.messages, msg)) + c.messages = trimHistory(append(c.messages, msgs...)) } // reconcile answers any tool call the run left open. A run stopped between the diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 4bb279a..bd2e3cf 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -12,17 +12,20 @@ import ( "testing" "time" + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" + "nodeshell/internal/apperror" "nodeshell/internal/mcpcli" "nodeshell/internal/permission" "nodeshell/internal/sftpservice" ) -// The agent loop is exercised against a fake OpenAI-compatible endpoint and a -// fake session execer: no network, no SSH. What is asserted is the contract the -// UI and the remote host depend on — streamed deltas, tool dispatch bound to -// the caller's session, exactly one done event per accepted prompt, bounded -// transcripts, and no API key on any surface but the request header. +// The agent is exercised against a scripted model and a fake session execer: +// no network, no SSH. What is asserted is the contract the UI and the remote +// host depend on — streamed deltas, tool dispatch bound to the caller's +// session, exactly one done event per accepted prompt, bounded transcripts, +// and no API key on any surface but the request header. // --- fakes --- @@ -99,6 +102,143 @@ func (s *recSink) done() []DoneEvent { return out } +// --- scripted model --- + +// fakeTurn is one scripted assistant turn: the text frames it streams, the +// reasoning it hides, and the tools it asks for. +type fakeTurn struct { + chunks []string + reasoning string + calls []schema.ToolCall + // err fails the model call itself, the way a provider rejects a request. + err error + // block holds the stream open after the frames until the run is + // cancelled, so a test can abort in the middle of an answer. + block bool +} + +// toolCall is the shorthand for one requested tool call. +func toolCallFor(id, name, args string) schema.ToolCall { + return schema.ToolCall{ + ID: id, + Type: "function", + Function: schema.FunctionCall{Name: name, Arguments: args}, + } +} + +// fakeModel is a scripted model.ToolCallingChatModel. It replays its turns +// one per request and records what it was asked, so a test can assert what +// the transcript looked like when the model was called. +type fakeModel struct { + mu sync.Mutex + turns []fakeTurn + calls int + // requests holds the message list of every call, in order. + requests [][]*schema.Message + // tools holds the tool schemas the agent advertised. + tools []*schema.ToolInfo +} + +func (f *fakeModel) Generate(ctx context.Context, in []*schema.Message, opts ...model.Option) (*schema.Message, error) { + turn, err := f.next(in, opts) + if err != nil { + return nil, err + } + return concatChunks(turn.frames()), nil +} + +func (f *fakeModel) Stream(ctx context.Context, in []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) { + turn, err := f.next(in, opts) + if err != nil { + return nil, err + } + frames := turn.frames() + if !turn.block { + return schema.StreamReaderFromArray(frames), nil + } + sr, sw := schema.Pipe[*schema.Message](1) + go func() { + defer sw.Close() + for _, frame := range frames { + if sw.Send(frame, nil) { + return + } + } + // The answer never finishes: only the cancellation ends the stream. + <-ctx.Done() + sw.Send(nil, ctx.Err()) + }() + return sr, nil +} + +func (f *fakeModel) WithTools(tools []*schema.ToolInfo) (model.ToolCallingChatModel, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.tools = tools + return f, nil +} + +func (f *fakeModel) GetType() string { return "fake" } + +func (f *fakeModel) IsCallbacksEnabled() bool { return false } + +// next records the request and returns the scripted turn for it. +func (f *fakeModel) next(in []*schema.Message, opts []model.Option) (fakeTurn, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.requests = append(f.requests, in) + if common := model.GetCommonOptions(&model.Options{}, opts...); len(common.Tools) > 0 { + f.tools = common.Tools + } + if f.calls >= len(f.turns) { + return fakeTurn{}, fmt.Errorf("fake model: no scripted turn %d", f.calls) + } + turn := f.turns[f.calls] + f.calls++ + return turn, turn.err +} + +// frames turns a scripted turn into the message frames the model streams. +func (t fakeTurn) frames() []*schema.Message { + frames := make([]*schema.Message, 0, len(t.chunks)+2) + for _, chunk := range t.chunks { + frames = append(frames, &schema.Message{Role: schema.Assistant, Content: chunk}) + } + if t.reasoning != "" { + frames = append(frames, &schema.Message{Role: schema.Assistant, ReasoningContent: t.reasoning}) + } + if len(t.calls) > 0 { + frames = append(frames, &schema.Message{Role: schema.Assistant, ToolCalls: t.calls}) + } + return frames +} + +// request returns the messages of the n-th model call. +func (f *fakeModel) request(n int) []*schema.Message { + f.mu.Lock() + defer f.mu.Unlock() + if n >= len(f.requests) { + return nil + } + return f.requests[n] +} + +// callCount is the number of model calls made so far. +func (f *fakeModel) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.calls +} + +// advertisedTools returns the tool schemas the agent offered to the model. +func (f *fakeModel) advertisedTools() []*schema.ToolInfo { + f.mu.Lock() + defer f.mu.Unlock() + return f.tools +} + +// --- fake tools --- + type toolCallRec struct { sessionID string name string @@ -200,107 +340,28 @@ func (f *fakeTools) recorded() []toolCallRec { return append([]toolCallRec(nil), f.calls...) } -// --- fake endpoint --- - -// scriptedCall is one tool call the fake model requests. -type scriptedCall struct { - name string - args string -} - -// tool is the single-call shorthand for a scripted turn. -func tool(name, args string) []scriptedCall { - return []scriptedCall{{name: name, args: args}} -} - -// sseTurn is one scripted assistant turn: text fragments and any tool calls. -type sseTurn struct { - deltas []string - calls []scriptedCall -} - -// turnServer serves the scripted turns in order, one per request, and records -// every request body plus the Authorization header. -type turnServer struct { - mu sync.Mutex - turns []sseTurn - requests []chatRequest - auths []string - served int -} - -func (ts *turnServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { - raw, _ := io.ReadAll(r.Body) - var req chatRequest - _ = json.Unmarshal(raw, &req) - - ts.mu.Lock() - ts.requests = append(ts.requests, req) - ts.auths = append(ts.auths, r.Header.Get("Authorization")) - idx := ts.served - ts.served++ - var turn sseTurn - if idx < len(ts.turns) { - turn = ts.turns[idx] - } - ts.mu.Unlock() - - w.Header().Set("Content-Type", "text/event-stream") - flusher, _ := w.(http.Flusher) - for _, d := range turn.deltas { - fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":%s}}]}\n\n", jsonString(d)) - if flusher != nil { - flusher.Flush() - } - } - for i, call := range turn.calls { - // Split the arguments across two fragments: assembling them per index - // is part of the protocol the loop must implement. - half := len(call.args) / 2 - fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":%d,\"id\":%s,\"type\":\"function\",\"function\":{\"name\":%s,\"arguments\":%s}}]}}]}\n\n", - i, jsonString(fmt.Sprintf("call_%d", i+1)), jsonString(call.name), jsonString(call.args[:half])) - fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":%d,\"function\":{\"arguments\":%s}}]}}]}\n\n", - i, jsonString(call.args[half:])) - } - fmt.Fprint(w, "data: [DONE]\n\n") -} - -func (ts *turnServer) recorded() ([]chatRequest, []string) { - ts.mu.Lock() - defer ts.mu.Unlock() - return append([]chatRequest(nil), ts.requests...), append([]string(nil), ts.auths...) -} - -func jsonString(s string) string { - b, _ := json.Marshal(s) - return string(b) -} - // --- harness --- type harness struct { - svc *Service - sink *recSink - tools *fakeTools - server *turnServer - url string + svc *Service + sink *recSink + tools *fakeTools + model *fakeModel } -func newHarness(t *testing.T, turns []sseTurn, tweak func(*Deps)) *harness { +func newHarness(t *testing.T, turns []fakeTurn, tweak func(*Deps)) *harness { t.Helper() - ts := &turnServer{turns: turns} - srv := httptest.NewServer(ts) - t.Cleanup(srv.Close) - h := &harness{ - sink: &recSink{}, - tools: &fakeTools{out: "ok"}, - server: ts, - url: srv.URL, + sink: &recSink{}, + tools: &fakeTools{out: "ok"}, + model: &fakeModel{turns: turns}, } deps := Deps{ - Tools: h.tools, - Sink: h.sink, + Tools: h.tools, + Sink: h.sink, + Model: func(context.Context, Config) (model.ToolCallingChatModel, error) { + return h.model, nil + }, RequestTimeout: 5 * time.Second, ExecTimeout: 2 * time.Second, } @@ -313,13 +374,24 @@ func newHarness(t *testing.T, turns []sseTurn, tweak func(*Deps)) *harness { } func (h *harness) cfg() Config { - return Config{BaseURL: h.url, Model: "test-model", APIKey: "sk-test-key"} + return Config{BaseURL: "https://model.test/v1", Model: "test-model", APIKey: "sk-test-key"} } func (h *harness) prompt(sessionID, title, text string) error { return h.svc.Prompt(sessionID, title, text, h.cfg()) } +// history returns the transcript of a session. +func (h *harness) history(sessionID string) []chatMessage { + h.svc.mu.Lock() + defer h.svc.mu.Unlock() + c := h.svc.convs[sessionID] + if c == nil { + return nil + } + return append([]chatMessage(nil), c.messages...) +} + // waitDone blocks until n runs have closed; every accepted prompt must emit // exactly one done event, so this is the run's completion signal. func (h *harness) waitDone(t *testing.T, n int) { @@ -337,11 +409,23 @@ func (h *harness) waitDone(t *testing.T, n int) { } } +// waitFor polls until the condition holds. +func waitFor(t *testing.T, what string, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for !cond() { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %s", what) + } + time.Sleep(5 * time.Millisecond) + } +} + // --- tests --- // A plain answer streams to the UI as deltas and closes with one done event. func TestPromptStreamsTextAndClosesOnce(t *testing.T) { - h := newHarness(t, []sseTurn{{deltas: []string{"Disk ", "looks ", "fine."}}}, nil) + h := newHarness(t, []fakeTurn{{chunks: []string{"Disk ", "looks ", "fine."}}}, nil) if err := h.prompt("s1", "prod-web", "how is the disk?"); err != nil { t.Fatalf("Prompt: %v", err) @@ -365,9 +449,9 @@ func TestPromptStreamsTextAndClosesOnce(t *testing.T) { // The tool call runs against the session the prompt named — never one the // model could pick — and the loop feeds the result back for a second turn. func TestToolCallRunsOnPromptSessionAndLoopsBack(t *testing.T) { - h := newHarness(t, []sseTurn{ - {deltas: []string{"checking"}, calls: tool(toolBash, `{"command":"df -h"}`)}, - {deltas: []string{"all good"}}, + h := newHarness(t, []fakeTurn{ + {chunks: []string{"checking"}, calls: []schema.ToolCall{toolCallFor("call_1", toolBash, `{"command":"df -h"}`)}}, + {chunks: []string{"all good"}}, }, nil) h.tools.out = "/dev/sda1 40% /" @@ -390,23 +474,38 @@ func TestToolCallRunsOnPromptSessionAndLoopsBack(t *testing.T) { if len(tools) != 1 || tools[0].Name != toolBash || !tools[0].OK || tools[0].Summary != "df -h" { t.Fatalf("tool events = %+v", tools) } + if tools[0].CallID != "call_1" { + t.Fatalf("tool call id = %q, want call_1", tools[0].CallID) + } if got := h.sink.text(); got != "checkingall good" { t.Fatalf("text across turns = %q", got) } + // The panel sees the answer, then the tool, then the answer that followed + // the tool, and only then the closing done event. + var order []string + for _, e := range h.sink.snapshot() { + order = append(order, e.name) + } + want := []string{EventDelta, EventTool, EventDelta, EventDone} + if len(order) != len(want) { + t.Fatalf("event order = %v, want %v", order, want) + } + for i := range want { + if order[i] != want[i] { + t.Fatalf("event order = %v, want %v", order, want) + } + } + // The follow-up request must carry the assistant's tool call and the tool // result, otherwise the endpoint rejects the transcript. - reqs, _ := h.server.recorded() - if len(reqs) != 2 { - t.Fatalf("requests = %d, want 2 turns", len(reqs)) - } - last := reqs[1].Messages + last := h.model.request(1) var sawCall, sawResult bool for _, m := range last { - if m.Role == roleAssistant && len(m.ToolCalls) == 1 { + if m.Role == schema.Assistant && len(m.ToolCalls) == 1 { sawCall = true } - if m.Role == roleTool && m.ToolCallID == "call_1" && strings.Contains(m.Content, "/dev/sda1") { + if m.Role == schema.Tool && m.ToolCallID == "call_1" && strings.Contains(m.Content, "/dev/sda1") { sawResult = true } } @@ -415,12 +514,40 @@ func TestToolCallRunsOnPromptSessionAndLoopsBack(t *testing.T) { } } -// A sessionId the model stuffed into the tool arguments is ignored: the loop +// The transcript keeps the tool pairing after the run, so the next prompt +// replays a conversation the endpoint accepts. +func TestToolCallIsAppendedToHistory(t *testing.T) { + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolBash, `{"command":"df -h"}`)}}, + {chunks: []string{"done"}}, + }, nil) + + if err := h.prompt("s1", "host", "check disk"); err != nil { + t.Fatalf("Prompt: %v", err) + } + h.waitDone(t, 1) + + history := h.history("s1") + var sawAssistant, sawTool bool + for _, m := range history { + if m.Role == roleAssistant && len(m.ToolCalls) == 1 && m.ToolCalls[0].ID == "call_1" { + sawAssistant = true + } + if m.Role == roleTool && m.ToolCallID == "call_1" { + sawTool = true + } + } + if !sawAssistant || !sawTool { + t.Fatalf("history lost the tool pairing: %+v", history) + } +} + +// A sessionId the model stuffed into the tool arguments is ignored: the tool // always addresses the session Prompt named. func TestToolCallIgnoresSessionIdInArguments(t *testing.T) { - h := newHarness(t, []sseTurn{ - {calls: tool(toolBash, `{"command":"id","sessionId":"other-host"}`)}, - {deltas: []string{"ok"}}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolBash, `{"command":"id","sessionId":"other-host"}`)}}, + {chunks: []string{"ok"}}, }, nil) if err := h.prompt("s7", "prod-web", "whoami"); err != nil { @@ -437,9 +564,9 @@ func TestToolCallIgnoresSessionIdInArguments(t *testing.T) { // A model-supplied timeout is clamped to the service bound, so the model can // shorten a command but never outlast the configured limit. func TestBashTimeoutIsClamped(t *testing.T) { - h := newHarness(t, []sseTurn{ - {calls: tool(toolBash, `{"command":"sleep 1","timeoutMs":600000}`)}, - {deltas: []string{"done"}}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolBash, `{"command":"sleep 1","timeoutMs":600000}`)}}, + {chunks: []string{"done"}}, }, nil) if err := h.prompt("s1", "host", "run it"); err != nil { @@ -456,9 +583,9 @@ func TestBashTimeoutIsClamped(t *testing.T) { // A failing tool is reported to the UI and handed back to the model as text: // the run continues instead of dying on a bad command. func TestFailedToolIsReportedAndFedBack(t *testing.T) { - h := newHarness(t, []sseTurn{ - {calls: tool(toolBash, `{"command":"cat /root/x"}`)}, - {deltas: []string{"permission denied"}}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolBash, `{"command":"cat /root/x"}`)}}, + {chunks: []string{"permission denied"}}, }, nil) h.tools.err = &Error{Code: apperror.Unknown, Message: "exit status 1"} @@ -477,10 +604,9 @@ func TestFailedToolIsReportedAndFedBack(t *testing.T) { if errs := h.sink.errors(); len(errs) != 0 { t.Fatalf("a failed tool must not fail the run: %v", errs) } - reqs, _ := h.server.recorded() var fed bool - for _, m := range reqs[1].Messages { - if m.Role == roleTool && strings.HasPrefix(m.Content, "error:") { + for _, m := range h.model.request(1) { + if m.Role == schema.Tool && strings.HasPrefix(m.Content, "error:") { fed = true } } @@ -489,13 +615,54 @@ func TestFailedToolIsReportedAndFedBack(t *testing.T) { } } +// Several tool calls in one turn run one after another, in the order the +// model asked for them. +func TestMultipleToolCallsRunInOrder(t *testing.T) { + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{ + toolCallFor("call_1", toolBash, `{"command":"first"}`), + toolCallFor("call_2", toolBash, `{"command":"second"}`), + toolCallFor("call_3", toolSftpRead, `{"path":"/etc/hosts"}`), + }}, + {chunks: []string{"all done"}}, + }, nil) + + if err := h.prompt("s1", "host", "do three things"); err != nil { + t.Fatalf("Prompt: %v", err) + } + h.waitDone(t, 1) + + calls := h.tools.recorded() + if len(calls) != 3 { + t.Fatalf("tool calls = %d, want 3", len(calls)) + } + want := []string{toolBash, toolBash, toolSftpRead} + for i, name := range want { + if calls[i].name != name { + t.Fatalf("tool %d = %q, want %q", i, calls[i].name, name) + } + } + if calls[0].command != "first" || calls[1].command != "second" { + t.Fatalf("tools ran out of order: %+v", calls) + } + tools := h.sink.tools() + if len(tools) != 3 { + t.Fatalf("tool events = %+v, want 3", tools) + } + for i, id := range []string{"call_1", "call_2", "call_3"} { + if tools[i].CallID != id { + t.Fatalf("tool event %d = %q, want %q", i, tools[i].CallID, id) + } + } +} + // A denied sensitive tool must not touch the remote host; the denial is a // failed tool result so the model can stop, not a run-level error. func TestDeniedSensitiveToolDoesNotExec(t *testing.T) { auth := permission.NewService(permission.ServiceDeps{Gate: permission.DenyGate{}}) - h := newHarness(t, []sseTurn{ - {calls: tool(toolBash, `{"command":"rm -rf /"}`)}, - {deltas: []string{"blocked"}}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolBash, `{"command":"rm -rf /"}`)}}, + {chunks: []string{"blocked"}}, }, nil) h.tools.auth = auth @@ -519,9 +686,9 @@ func TestDeniedSensitiveToolDoesNotExec(t *testing.T) { // Reads are not gated: a deny-all Gate must still let sftp_read through. func TestReadToolSkipsPermissionGate(t *testing.T) { auth := permission.NewService(permission.ServiceDeps{Gate: permission.DenyGate{}}) - h := newHarness(t, []sseTurn{ - {calls: tool(toolSftpRead, `{"path":"/etc/hosts"}`)}, - {deltas: []string{"ok"}}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolSftpRead, `{"path":"/etc/hosts"}`)}}, + {chunks: []string{"ok"}}, }, nil) h.tools.auth = auth h.tools.content = "127.0.0.1 localhost" @@ -550,9 +717,9 @@ func TestDeniedWriteDoesNotWrite(t *testing.T) { return permission.DecisionDeny }}, }) - h := newHarness(t, []sseTurn{ - {calls: tool(toolSftpWrite, `{"path":"/tmp/x","content":"SECRET"}`)}, - {deltas: []string{"no"}}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolSftpWrite, `{"path":"/tmp/x","content":"SECRET"}`)}}, + {chunks: []string{"no"}}, }, nil) h.tools.auth = auth @@ -586,10 +753,10 @@ func TestFileToolsUseMCPByteCap(t *testing.T) { if MaxFileBytes != mcpcli.MaxFileBytes { t.Fatalf("agent file cap = %d, want MCP MaxFileBytes (%d)", MaxFileBytes, mcpcli.MaxFileBytes) } - h := newHarness(t, []sseTurn{ - {calls: tool(toolSftpRead, `{"path":"/etc/hosts"}`)}, - {calls: tool(toolSftpWrite, `{"path":"/tmp/out","content":"hello"}`)}, - {deltas: []string{"written"}}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolSftpRead, `{"path":"/etc/hosts"}`)}}, + {calls: []schema.ToolCall{toolCallFor("call_2", toolSftpWrite, `{"path":"/tmp/out","content":"hello"}`)}}, + {chunks: []string{"written"}}, }, nil) h.tools.content = "127.0.0.1 localhost" @@ -613,21 +780,38 @@ func TestFileToolsUseMCPByteCap(t *testing.T) { // Abort stops a blocked tool and closes the run as aborted, not as a failure: // the user asked for the stop. func TestAbortStopsRunAndReportsAborted(t *testing.T) { - h := newHarness(t, []sseTurn{ - {calls: tool(toolBash, `{"command":"tail -f /var/log/syslog"}`)}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolBash, `{"command":"tail -f /var/log/syslog"}`)}}, }, nil) h.tools.block = true if err := h.prompt("s1", "host", "tail the log"); err != nil { t.Fatalf("Prompt: %v", err) } - deadline := time.Now().Add(5 * time.Second) - for len(h.tools.recorded()) == 0 { - if time.Now().After(deadline) { - t.Fatal("timed out waiting for the tool to start") - } - time.Sleep(5 * time.Millisecond) + waitFor(t, "the tool to start", func() bool { return len(h.tools.recorded()) > 0 }) + h.svc.Abort("s1") + h.waitDone(t, 1) + + done := h.sink.done() + if len(done) != 1 || !done[0].Aborted { + t.Fatalf("done events = %+v, want one aborted", done) + } + if errs := h.sink.errors(); len(errs) != 0 { + t.Fatalf("an abort must not surface as an error: %v", errs) } +} + +// Aborting in the middle of an answer closes the run as aborted and reports +// no error: the user asked for the stop. +func TestAbortMidStreamReportsAborted(t *testing.T) { + h := newHarness(t, []fakeTurn{ + {chunks: []string{"partial "}, block: true}, + }, nil) + + if err := h.prompt("s1", "host", "tell me a story"); err != nil { + t.Fatalf("Prompt: %v", err) + } + waitFor(t, "the first delta", func() bool { return h.sink.count(EventDelta) > 0 }) h.svc.Abort("s1") h.waitDone(t, 1) @@ -641,30 +825,24 @@ func TestAbortStopsRunAndReportsAborted(t *testing.T) { } // Aborting a turn that requested several tools leaves the later calls -// unanswered: the loop stops before running them. The transcript must still be -// one the endpoint accepts, because an assistant tool_calls message without a -// result for every call is rejected — the abort would otherwise break the whole -// conversation instead of just ending the run. +// unanswered: the transcript must still be one the endpoint accepts, because +// an assistant tool_calls message without a result for every call is +// rejected — the abort would otherwise break the whole conversation instead +// of just ending the run. func TestAbortMidToolLeavesUsableTranscript(t *testing.T) { - h := newHarness(t, []sseTurn{ - {calls: []scriptedCall{ - {name: toolBash, args: `{"command":"tail -f log"}`}, - {name: toolBash, args: `{"command":"uptime"}`}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{ + toolCallFor("call_1", toolBash, `{"command":"tail -f log"}`), + toolCallFor("call_2", toolBash, `{"command":"uptime"}`), }}, - {deltas: []string{"back again"}}, + {chunks: []string{"back again"}}, }, nil) h.tools.block = true if err := h.prompt("s1", "host", "tail it"); err != nil { t.Fatalf("Prompt: %v", err) } - deadline := time.Now().Add(5 * time.Second) - for len(h.tools.recorded()) == 0 { - if time.Now().After(deadline) { - t.Fatal("timed out waiting for the tool to start") - } - time.Sleep(5 * time.Millisecond) - } + waitFor(t, "the tool to start", func() bool { return len(h.tools.recorded()) > 0 }) h.svc.Abort("s1") h.waitDone(t, 1) @@ -679,12 +857,11 @@ func TestAbortMidToolLeavesUsableTranscript(t *testing.T) { if errs := h.sink.errors(); len(errs) != 0 { t.Fatalf("the follow-up run failed: %v", errs) } - reqs, _ := h.server.recorded() - msgs := reqs[len(reqs)-1].Messages + msgs := h.model.request(h.model.callCount() - 1) answered := map[string]bool{} var pending []string for _, m := range msgs { - if m.Role == roleTool { + if m.Role == schema.Tool { answered[m.ToolCallID] = true } for _, call := range m.ToolCalls { @@ -701,21 +878,15 @@ func TestAbortMidToolLeavesUsableTranscript(t *testing.T) { // A second prompt while a run is in flight is rejected up front, so one // session can never have two loops appending to the same transcript. func TestConcurrentPromptRejected(t *testing.T) { - h := newHarness(t, []sseTurn{ - {calls: tool(toolBash, `{"command":"sleep"}`)}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolBash, `{"command":"sleep"}`)}}, }, nil) h.tools.block = true if err := h.prompt("s1", "host", "first"); err != nil { t.Fatalf("Prompt: %v", err) } - deadline := time.Now().Add(5 * time.Second) - for len(h.tools.recorded()) == 0 { - if time.Now().After(deadline) { - t.Fatal("timed out waiting for the first run") - } - time.Sleep(5 * time.Millisecond) - } + waitFor(t, "the first run", func() bool { return len(h.tools.recorded()) > 0 }) if err := h.prompt("s1", "host", "second"); err == nil { t.Fatal("a second prompt during a run must be rejected") } @@ -726,22 +897,16 @@ func TestConcurrentPromptRejected(t *testing.T) { // Dispose (session:closed) cancels the run and drops the transcript, so a // reconnect under the same id never inherits the old conversation. func TestDisposeCancelsRunAndDropsHistory(t *testing.T) { - h := newHarness(t, []sseTurn{ - {calls: tool(toolBash, `{"command":"sleep"}`)}, - {deltas: []string{"fresh"}}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolBash, `{"command":"sleep"}`)}}, + {chunks: []string{"fresh"}}, }, nil) h.tools.block = true if err := h.prompt("s1", "host", "remember this"); err != nil { t.Fatalf("Prompt: %v", err) } - deadline := time.Now().Add(5 * time.Second) - for len(h.tools.recorded()) == 0 { - if time.Now().After(deadline) { - t.Fatal("timed out waiting for the run to start") - } - time.Sleep(5 * time.Millisecond) - } + waitFor(t, "the run to start", func() bool { return len(h.tools.recorded()) > 0 }) h.svc.Dispose("s1") if got := h.sink.count(EventDone); got != 0 { t.Fatalf("disposed run must not emit done (got %d)", got) @@ -755,11 +920,9 @@ func TestDisposeCancelsRunAndDropsHistory(t *testing.T) { } h.waitDone(t, 1) - reqs, _ := h.server.recorded() - last := reqs[len(reqs)-1].Messages - for _, m := range last { + for _, m := range h.model.request(h.model.callCount() - 1) { if strings.Contains(m.Content, "remember this") { - t.Fatalf("disposed transcript leaked into a later run: %+v", last) + t.Fatalf("disposed transcript leaked into a later run: %+v", m) } } } @@ -767,22 +930,16 @@ func TestDisposeCancelsRunAndDropsHistory(t *testing.T) { // Clear detaches the in-flight conversation so the next prompt cannot replay // the emptied history, even if the cancelled run later tries to append. func TestClearDuringRunDropsHistory(t *testing.T) { - h := newHarness(t, []sseTurn{ - {calls: tool(toolBash, `{"command":"sleep"}`)}, - {deltas: []string{"fresh"}}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolBash, `{"command":"sleep"}`)}}, + {chunks: []string{"fresh"}}, }, nil) h.tools.block = true if err := h.prompt("s1", "host", "remember this"); err != nil { t.Fatalf("Prompt: %v", err) } - deadline := time.Now().Add(5 * time.Second) - for len(h.tools.recorded()) == 0 { - if time.Now().After(deadline) { - t.Fatal("timed out waiting for the run to start") - } - time.Sleep(5 * time.Millisecond) - } + waitFor(t, "the run to start", func() bool { return len(h.tools.recorded()) > 0 }) h.svc.Clear("s1") h.tools.mu.Lock() @@ -793,11 +950,9 @@ func TestClearDuringRunDropsHistory(t *testing.T) { } h.waitDone(t, 1) - reqs, _ := h.server.recorded() - last := reqs[len(reqs)-1].Messages - for _, m := range last { + for _, m := range h.model.request(h.model.callCount() - 1) { if strings.Contains(m.Content, "remember this") { - t.Fatalf("cleared transcript leaked into a later run: %+v", last) + t.Fatalf("cleared transcript leaked into a later run: %+v", m) } } if h.sink.count(EventDone) != 1 { @@ -809,8 +964,8 @@ func TestClearDuringRunDropsHistory(t *testing.T) { // cannot tear down SSH under a live bash/sftp_write. func TestDisposeAllJoinsInFlightRun(t *testing.T) { park := make(chan struct{}) - h := newHarness(t, []sseTurn{ - {calls: tool(toolBash, `{"command":"sleep"}`)}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolBash, `{"command":"sleep"}`)}}, }, nil) h.tools.park = park t.Cleanup(func() { @@ -824,13 +979,7 @@ func TestDisposeAllJoinsInFlightRun(t *testing.T) { if err := h.prompt("s1", "host", "hold"); err != nil { t.Fatalf("Prompt: %v", err) } - deadline := time.Now().Add(5 * time.Second) - for len(h.tools.recorded()) == 0 { - if time.Now().After(deadline) { - t.Fatal("timed out waiting for the run to start") - } - time.Sleep(5 * time.Millisecond) - } + waitFor(t, "the run to start", func() bool { return len(h.tools.recorded()) > 0 }) finished := make(chan struct{}) go func() { @@ -866,13 +1015,15 @@ func (h *hookAuthorizer) Authorize(ctx context.Context, req permission.Request) // returns a final answer. func TestDefaultRunCanExceedEightToolTurnsAndFinish(t *testing.T) { const totalToolTurns = 12 - turns := make([]sseTurn, totalToolTurns+1) + turns := make([]fakeTurn, totalToolTurns+1) for i := 0; i < totalToolTurns; i++ { - turns[i] = sseTurn{calls: tool(toolBash, fmt.Sprintf(`{"command":"echo step %d"}`, i+1))} + turns[i] = fakeTurn{calls: []schema.ToolCall{ + toolCallFor(fmt.Sprintf("call_%d", i+1), toolBash, fmt.Sprintf(`{"command":"echo step %d"}`, i+1)), + }} } - turns[totalToolTurns] = sseTurn{deltas: []string{"all 12 steps completed successfully"}} + turns[totalToolTurns] = fakeTurn{chunks: []string{"all 12 steps completed successfully"}} - h := newHarness(t, turns, nil) // Deps.MaxTurns defaults to 0 (unlimited) + h := newHarness(t, turns, nil) // Deps.MaxTurns defaults to 0 (Eino's own budget) if err := h.prompt("s1", "host", "run 12-step task"); err != nil { t.Fatalf("Prompt: %v", err) } @@ -900,9 +1051,11 @@ func TestDefaultRunCanExceedEightToolTurnsAndFinish(t *testing.T) { // An unlimited run executing beyond 8 turns can be aborted cleanly at any turn. func TestUnlimitedRunCanAbortAfterEightTurns(t *testing.T) { const totalTurns = 15 - turns := make([]sseTurn, totalTurns) + turns := make([]fakeTurn, totalTurns) for i := range turns { - turns[i] = sseTurn{calls: tool(toolBash, fmt.Sprintf(`{"command":"echo step %d"}`, i+1))} + turns[i] = fakeTurn{calls: []schema.ToolCall{ + toolCallFor(fmt.Sprintf("call_%d", i+1), toolBash, fmt.Sprintf(`{"command":"echo step %d"}`, i+1)), + }} } var h *harness @@ -942,9 +1095,11 @@ func TestUnlimitedRunCanAbortAfterEightTurns(t *testing.T) { // An explicit turn limit (MaxTurns > 0) is observable: a model that keeps // calling tools ends with an error event rather than spinning indefinitely. func TestExplicitTurnLimitEndsWithError(t *testing.T) { - turns := make([]sseTurn, 6) + turns := make([]fakeTurn, 6) for i := range turns { - turns[i] = sseTurn{calls: tool(toolBash, `{"command":"echo loop"}`)} + turns[i] = fakeTurn{calls: []schema.ToolCall{ + toolCallFor(fmt.Sprintf("call_%d", i+1), toolBash, `{"command":"echo loop"}`), + }} } h := newHarness(t, turns, func(d *Deps) { d.MaxTurns = 3 }) @@ -962,65 +1117,44 @@ func TestExplicitTurnLimitEndsWithError(t *testing.T) { } } -// The API key goes into the Authorization header and nowhere else: not into -// the request body, and not into any event the WebView can read. -func TestAPIKeyOnlyTravelsInAuthorizationHeader(t *testing.T) { - h := newHarness(t, []sseTurn{{deltas: []string{"hi"}}}, nil) +// The reasoning of the model is kept in the transcript but never rendered. +func TestReasoningContentIsKeptButNotRendered(t *testing.T) { + h := newHarness(t, []fakeTurn{ + {chunks: []string{"the answer"}, reasoning: "let me think about it"}, + {chunks: []string{"and again"}}, + }, nil) - if err := h.prompt("s1", "host", "hello"); err != nil { + if err := h.prompt("s1", "host", "think"); err != nil { t.Fatalf("Prompt: %v", err) } h.waitDone(t, 1) - reqs, auths := h.server.recorded() - if len(auths) != 1 || auths[0] != "Bearer sk-test-key" { - t.Fatalf("authorization headers = %v", auths) + if strings.Contains(h.sink.text(), "let me think") { + t.Fatalf("the reasoning leaked into the panel: %q", h.sink.text()) } - body, _ := json.Marshal(reqs) - if strings.Contains(string(body), "sk-test-key") { - t.Fatal("the API key must not appear in the request body") + var reasoning string + for _, m := range h.history("s1") { + if m.Role == roleAssistant && m.ReasoningContent != "" { + reasoning = m.ReasoningContent + } } - events, _ := json.Marshal(h.sink.snapshot()) - if strings.Contains(string(events), "sk-test-key") { - t.Fatal("the API key must not appear in any emitted event") + if reasoning != "let me think about it" { + t.Fatalf("reasoning content = %q, want it kept in the transcript", reasoning) } -} -// An endpoint that echoes the key in an error must not put it on screen. -func TestProviderErrorRedactsKeyAndCodesTheFailure(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnauthorized) - fmt.Fprint(w, `{"error":{"message":"Incorrect API key provided: sk-test-key"}}`) - })) - defer srv.Close() - - sink := &recSink{} - svc := New(Deps{ - Tools: &fakeTools{}, - Sink: sink, - }) - defer svc.DisposeAll() - - if err := svc.Prompt("s1", "host", "hello", Config{BaseURL: srv.URL, Model: "m", APIKey: "sk-test-key"}); err != nil { + // The next prompt replays the reasoning to the provider. + if err := h.prompt("s1", "host", "and now?"); err != nil { t.Fatalf("Prompt: %v", err) } - deadline := time.Now().Add(5 * time.Second) - for sink.count(EventDone) == 0 { - if time.Now().After(deadline) { - t.Fatal("timed out waiting for the failed run to close") + h.waitDone(t, 2) + var replayed bool + for _, m := range h.model.request(1) { + if m.Role == schema.Assistant && m.ReasoningContent == "let me think about it" { + replayed = true } - time.Sleep(5 * time.Millisecond) - } - errs := sink.errors() - if len(errs) != 1 { - t.Fatalf("error events = %+v, want one", errs) } - if strings.Contains(errs[0].Error.Message, "sk-test-key") { - t.Fatalf("the key leaked into the error: %q", errs[0].Error.Message) - } - if !strings.Contains(errs[0].Error.Message, "[redacted]") || - !strings.Contains(errs[0].Error.Message, "401") { - t.Fatalf("error message = %q, want the redacted 401 detail", errs[0].Error.Message) + if !replayed { + t.Fatal("the reasoning was not replayed to the model") } } @@ -1052,7 +1186,7 @@ func TestPromptRejectedWhenUnconfigured(t *testing.T) { // After shutdown no run may start: the WebView is gone, so its events could // never be observed. func TestPromptRejectedAfterDisposeAll(t *testing.T) { - h := newHarness(t, []sseTurn{{deltas: []string{"hi"}}}, nil) + h := newHarness(t, []fakeTurn{{chunks: []string{"hi"}}}, nil) h.svc.DisposeAll() if err := h.prompt("s1", "host", "hello"); err == nil { t.Fatal("Prompt after DisposeAll must be rejected") @@ -1062,9 +1196,9 @@ func TestPromptRejectedAfterDisposeAll(t *testing.T) { // A tool result larger than the transcript cap is truncated with a marker, so // one noisy command cannot grow the next request without bound. func TestOversizedToolResultTruncated(t *testing.T) { - h := newHarness(t, []sseTurn{ - {calls: tool(toolBash, `{"command":"cat big"}`)}, - {deltas: []string{"ok"}}, + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", toolBash, `{"command":"cat big"}`)}}, + {chunks: []string{"ok"}}, }, nil) h.tools.out = strings.Repeat("y", MaxToolResultBytes*2) @@ -1073,9 +1207,8 @@ func TestOversizedToolResultTruncated(t *testing.T) { } h.waitDone(t, 1) - reqs, _ := h.server.recorded() - for _, m := range reqs[1].Messages { - if m.Role != roleTool { + for _, m := range h.model.request(1) { + if m.Role != schema.Tool { continue } if len(m.Content) > MaxToolResultBytes+64 { @@ -1108,37 +1241,311 @@ func TestTrimHistoryNeverLeavesOrphanToolResult(t *testing.T) { } } +// reconcile answers the tool calls a stopped run left open, so the next +// prompt replays a transcript the endpoint accepts. +func TestReconcileAnswersUnansweredToolCalls(t *testing.T) { + c := &conversation{messages: []chatMessage{ + {Role: roleUser, Content: "run it"}, + {Role: roleAssistant, ToolCalls: []toolCall{ + {ID: "call_1"}, + {ID: "call_2"}, + }}, + {Role: roleTool, ToolCallID: "call_1", Content: "ok"}, + }} + svc := New(Deps{}) + defer svc.DisposeAll() + svc.reconcile(c) + + var answered []string + for _, m := range c.messages { + if m.Role == roleTool { + answered = append(answered, m.ToolCallID) + } + } + if len(answered) != 2 || answered[1] != "call_2" { + t.Fatalf("answered calls = %v, want call_1 and call_2", answered) + } + if !strings.Contains(c.messages[len(c.messages)-1].Content, "stopped before") { + t.Fatalf("the unanswered call was not reconciled: %+v", c.messages) + } +} + // The system prompt pins the session's host and states the no-local-machine // boundary; the advertised tools are exactly the remote four. func TestSystemPromptAndToolSurface(t *testing.T) { - h := newHarness(t, []sseTurn{{deltas: []string{"hi"}}}, nil) + h := newHarness(t, []fakeTurn{{chunks: []string{"hi"}}}, nil) if err := h.prompt("s1", "prod-web", "hello"); err != nil { t.Fatalf("Prompt: %v", err) } h.waitDone(t, 1) - reqs, _ := h.server.recorded() - system := reqs[0].Messages[0] - if system.Role != roleSystem { - t.Fatalf("first message role = %q, want system", system.Role) + first := h.model.request(0) + if len(first) == 0 || first[0].Role != schema.System { + t.Fatalf("first message = %+v, want the system prompt", first) } - if !strings.Contains(system.Content, "prod-web") { - t.Fatalf("system prompt does not name the host: %q", system.Content) + system := first[0].Content + if !strings.Contains(system, "prod-web") { + t.Fatalf("system prompt does not name the host: %q", system) } - if !strings.Contains(system.Content, "cannot see the user's local machine") { - t.Fatalf("system prompt drops the local-machine boundary: %q", system.Content) + if !strings.Contains(system, "cannot see the user's local machine") { + t.Fatalf("system prompt drops the local-machine boundary: %q", system) } + + tools := h.model.advertisedTools() want := map[string]bool{toolBash: true, toolSftpList: true, toolSftpRead: true, toolSftpWrite: true} - if len(reqs[0].Tools) != len(want) { - t.Fatalf("advertised %d tools, want %d", len(reqs[0].Tools), len(want)) + if len(tools) != len(want) { + t.Fatalf("advertised %d tools, want %d", len(tools), len(want)) + } + for _, info := range tools { + if !want[info.Name] { + t.Fatalf("unexpected tool advertised: %q", info.Name) + } + if _, err := info.ParamsOneOf.ToJSONSchema(); err != nil { + t.Fatalf("%s has no parameter schema: %v", info.Name, err) + } + } +} + +// A hallucinated tool is refused as a failed call: the model gets the +// refusal as text and the run continues. +func TestUnknownToolIsRefusedWithoutEndingTheRun(t *testing.T) { + h := newHarness(t, []fakeTurn{ + {calls: []schema.ToolCall{toolCallFor("call_1", "rm_rf", `{"path":"/"}`)}}, + {chunks: []string{"I cannot do that"}}, + }, nil) + + if err := h.prompt("s1", "host", "wipe it"); err != nil { + t.Fatalf("Prompt: %v", err) + } + h.waitDone(t, 1) + + if calls := h.tools.recorded(); len(calls) != 0 { + t.Fatalf("an unknown tool must not reach the remote host: %+v", calls) + } + tools := h.sink.tools() + if len(tools) != 1 || tools[0].OK || tools[0].Name != "rm_rf" { + t.Fatalf("tool event = %+v", tools) + } + if errs := h.sink.errors(); len(errs) != 0 { + t.Fatalf("an unknown tool must not fail the run: %v", errs) + } + if got := h.sink.text(); got != "I cannot do that" { + t.Fatalf("streamed text = %q, want the model's answer", got) + } +} + +// A stalled provider is cut off by the request timeout instead of hanging the +// panel forever. +func TestRequestTimeoutEndsTheRun(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(300 * time.Millisecond) + })) + defer srv.Close() + + sink := &recSink{} + svc := New(Deps{ + Tools: &fakeTools{}, + Sink: sink, + RequestTimeout: 100 * time.Millisecond, + }) + defer svc.DisposeAll() + + cfg := Config{BaseURL: srv.URL, Model: "m", APIKey: "sk-test-key"} + if err := svc.Prompt("s1", "host", "hello", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + waitFor(t, "the failed run to close", func() bool { return sink.count(EventDone) == 1 }) + + errs := sink.errors() + if len(errs) != 1 { + t.Fatalf("error events = %+v, want one", errs) + } + if errs[0].Error.Code != apperror.Timeout { + t.Fatalf("error code = %q, want %q", errs[0].Error.Code, apperror.Timeout) + } +} + +// The request the model sees stays within the history cap, even when a long +// run produces far more messages than the cap allows. +func TestModelRequestStaysWithinHistoryCap(t *testing.T) { + const toolTurns = 25 + scripted := make([]fakeTurn, toolTurns+1) + for i := range scripted { + scripted[i] = fakeTurn{calls: []schema.ToolCall{ + toolCallFor(fmt.Sprintf("call_%d", i+1), toolBash, `{"command":"echo"}`), + }} + } + scripted[toolTurns] = fakeTurn{chunks: []string{"done"}} + + h := newHarness(t, scripted, func(d *Deps) { d.MaxTurns = toolTurns + 1 }) + if err := h.prompt("s1", "host", "loop a lot"); err != nil { + t.Fatalf("Prompt: %v", err) + } + h.waitDone(t, 1) + + last := h.model.request(h.model.callCount() - 1) + if len(last) > MaxHistoryMessages+1 { + t.Fatalf("request carries %d messages, want at most %d", len(last), MaxHistoryMessages+1) + } + if len(last) == 0 || last[0].Role != schema.System { + t.Fatalf("the system prompt was cut away: %+v", last) + } + // Every tool call the request carries must still have its result. + answered := map[string]bool{} + for _, m := range last { + if m.Role == schema.Tool { + answered[m.ToolCallID] = true + } } - for _, spec := range reqs[0].Tools { - if !want[spec.Function.Name] { - t.Fatalf("unexpected tool advertised: %q", spec.Function.Name) + for _, m := range last { + for _, call := range m.ToolCalls { + if !answered[call.ID] { + t.Fatalf("tool call %q lost its result in the trimmed request", call.ID) + } } - if _, ok := spec.Function.Parameters["properties"].(map[string]any)["sessionId"]; ok { - t.Fatalf("%s exposes a sessionId argument; the session must be injected", - spec.Function.Name) + } +} + +// An endpoint that cannot be reached is a coded failure, not a crash. +func TestUnreachableEndpointIsCoded(t *testing.T) { + sink := &recSink{} + svc := New(Deps{Tools: &fakeTools{}, Sink: sink}) + defer svc.DisposeAll() + + cfg := Config{BaseURL: "http://127.0.0.1:1/v1", Model: "m", APIKey: "sk-test-key"} + if err := svc.Prompt("s1", "host", "hello", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + waitFor(t, "the failed run to close", func() bool { return sink.count(EventDone) == 1 }) + + errs := sink.errors() + if len(errs) != 1 { + t.Fatalf("error events = %+v, want one", errs) + } + if errs[0].Error.Code != apperror.Unknown { + t.Fatalf("error code = %q, want %q", errs[0].Error.Code, apperror.Unknown) + } + if !strings.Contains(errs[0].Error.Message, "unreachable") { + t.Fatalf("error message = %q, want the unreachable endpoint", errs[0].Error.Message) + } +} + +// --- the real provider path --- + +// providerServer is a minimal OpenAI-compatible endpoint: it records the +// request and answers with the scripted SSE frames. +type providerServer struct { + mu sync.Mutex + requests []string + auths []string + bodies []string + frames []string + status int + body string +} + +func (p *providerServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + p.mu.Lock() + p.requests = append(p.requests, r.URL.Path) + p.auths = append(p.auths, r.Header.Get("Authorization")) + p.bodies = append(p.bodies, string(raw)) + status, body, frames := p.status, p.body, p.frames + p.mu.Unlock() + + if status != 0 { + w.WriteHeader(status) + fmt.Fprint(w, body) + return + } + w.Header().Set("Content-Type", "text/event-stream") + flusher, _ := w.(http.Flusher) + for _, frame := range frames { + fmt.Fprint(w, frame) + if flusher != nil { + flusher.Flush() } } + fmt.Fprint(w, "data: [DONE]\n\n") +} + +func (p *providerServer) recorded() ([]string, []string, []string) { + p.mu.Lock() + defer p.mu.Unlock() + return append([]string(nil), p.requests...), + append([]string(nil), p.auths...), + append([]string(nil), p.bodies...) +} + +// The API key goes into the Authorization header and nowhere else: not into +// the request body, and not into any event the WebView can read. +func TestAPIKeyOnlyTravelsInAuthorizationHeader(t *testing.T) { + prov := &providerServer{frames: []string{ + "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"hi\"}}]}\n\n", + }} + srv := httptest.NewServer(prov) + defer srv.Close() + + sink := &recSink{} + svc := New(Deps{Tools: &fakeTools{}, Sink: sink}) + defer svc.DisposeAll() + + cfg := Config{BaseURL: srv.URL + "/v1", Model: "test-model", APIKey: "sk-test-key"} + if err := svc.Prompt("s1", "host", "hello", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + waitFor(t, "the run to close", func() bool { return sink.count(EventDone) == 1 }) + + paths, auths, bodies := prov.recorded() + if len(paths) != 1 || paths[0] != "/v1/chat/completions" { + t.Fatalf("request paths = %v, want /v1/chat/completions", paths) + } + if len(auths) != 1 || auths[0] != "Bearer sk-test-key" { + t.Fatalf("authorization headers = %v", auths) + } + if strings.Contains(strings.Join(bodies, ""), "sk-test-key") { + t.Fatal("the API key must not appear in the request body") + } + events, _ := json.Marshal(sink.snapshot()) + if strings.Contains(string(events), "sk-test-key") { + t.Fatal("the API key must not appear in any emitted event") + } + if got := sink.text(); got != "hi" { + t.Fatalf("streamed text = %q, want hi", got) + } +} + +// An endpoint that echoes the key in an error must not put it on screen. +func TestProviderErrorRedactsKeyAndCodesTheFailure(t *testing.T) { + prov := &providerServer{ + status: http.StatusUnauthorized, + body: `{"error":{"message":"Incorrect API key provided: sk-test-key"}}`, + } + srv := httptest.NewServer(prov) + defer srv.Close() + + sink := &recSink{} + svc := New(Deps{Tools: &fakeTools{}, Sink: sink}) + defer svc.DisposeAll() + + cfg := Config{BaseURL: srv.URL, Model: "m", APIKey: "sk-test-key"} + if err := svc.Prompt("s1", "host", "hello", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + waitFor(t, "the failed run to close", func() bool { return sink.count(EventDone) == 1 }) + + errs := sink.errors() + if len(errs) != 1 { + t.Fatalf("error events = %+v, want one", errs) + } + if strings.Contains(errs[0].Error.Message, "sk-test-key") { + t.Fatalf("the key leaked into the error: %q", errs[0].Error.Message) + } + if !strings.Contains(errs[0].Error.Message, "[redacted]") || + !strings.Contains(errs[0].Error.Message, "401") { + t.Fatalf("error message = %q, want the redacted 401 detail", errs[0].Error.Message) + } + if sink.count(EventDone) != 1 { + t.Fatalf("a failed run must still close with one done event, got %d", sink.count(EventDone)) + } } diff --git a/internal/agent/convert.go b/internal/agent/convert.go new file mode 100644 index 0000000..8dfab27 --- /dev/null +++ b/internal/agent/convert.go @@ -0,0 +1,159 @@ +package agent + +import ( + "sort" + + "github.com/cloudwego/eino/schema" +) + +// Chat roles used in the transcript. They are the OpenAI-compatible wire +// roles, so the transcript stays readable on its own. +const ( + roleSystem = "system" + roleUser = "user" + roleAssistant = "assistant" + roleTool = "tool" +) + +// chatMessage is one OpenAI-compatible chat message. A tool result carries +// ToolCallID; an assistant turn that called tools carries ToolCalls. +// ReasoningContent keeps the model's thinking text: it is replayed to the +// provider on the next turn but never rendered in the UI. +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolCalls []toolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + ReasoningContent string `json:"reasoning_content,omitempty"` +} + +// toolCall is one requested tool invocation. +type toolCall struct { + ID string `json:"id"` + Type string `json:"type"` + Function functionCall `json:"function"` +} + +// functionCall names the tool and carries its JSON-encoded arguments. +type functionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +// toSchemaMessages converts the NodeShell transcript into the message list the +// Eino agent replays to the model. The system prompt is not part of it: the +// agent instruction carries that. +func toSchemaMessages(msgs []chatMessage) []*schema.Message { + out := make([]*schema.Message, 0, len(msgs)) + for _, m := range msgs { + out = append(out, toSchemaMessage(m)) + } + return out +} + +// toSchemaMessage converts one transcript message into its Eino counterpart. +func toSchemaMessage(m chatMessage) *schema.Message { + switch m.Role { + case roleSystem: + return schema.SystemMessage(m.Content) + case roleUser: + return schema.UserMessage(m.Content) + case roleAssistant: + msg := schema.AssistantMessage(m.Content, toSchemaToolCalls(m.ToolCalls)) + msg.ReasoningContent = m.ReasoningContent + return msg + case roleTool: + return schema.ToolMessage(m.Content, m.ToolCallID) + default: + return schema.UserMessage(m.Content) + } +} + +// toSchemaToolCalls converts the recorded tool calls into Eino tool calls. +func toSchemaToolCalls(calls []toolCall) []schema.ToolCall { + if len(calls) == 0 { + return nil + } + out := make([]schema.ToolCall, 0, len(calls)) + for _, c := range calls { + out = append(out, schema.ToolCall{ + ID: c.ID, + Type: c.Type, + Function: schema.FunctionCall{ + Name: c.Function.Name, + Arguments: c.Function.Arguments, + }, + }) + } + return out +} + +// fromSchemaMessage converts a message the Eino agent produced back into the +// transcript shape, so the NodeShell history stays the source of truth. +func fromSchemaMessage(msg *schema.Message) chatMessage { + if msg == nil { + return chatMessage{} + } + out := chatMessage{ + Role: fromSchemaRole(msg.Role), + Content: msg.Content, + ToolCallID: msg.ToolCallID, + ReasoningContent: msg.ReasoningContent, + } + if len(msg.ToolCalls) > 0 { + out.ToolCalls = fromSchemaToolCalls(msg.ToolCalls) + } + return out +} + +// fromSchemaRole maps an Eino role onto the transcript role. +func fromSchemaRole(role schema.RoleType) string { + switch role { + case schema.System: + return roleSystem + case schema.User: + return roleUser + case schema.Assistant: + return roleAssistant + case schema.Tool: + return roleTool + default: + return string(role) + } +} + +// fromSchemaToolCalls converts the tool calls of one assistant message. The +// provider streams parallel calls with an index; keeping that order is what +// makes the tools run in the order the model asked for them. +func fromSchemaToolCalls(calls []schema.ToolCall) []toolCall { + ordered := make([]schema.ToolCall, len(calls)) + copy(ordered, calls) + if allIndexed(ordered) { + sort.SliceStable(ordered, func(i, j int) bool { + return *ordered[i].Index < *ordered[j].Index + }) + } + out := make([]toolCall, 0, len(ordered)) + for _, c := range ordered { + out = append(out, toolCall{ + ID: c.ID, + Type: c.Type, + Function: functionCall{ + Name: c.Function.Name, + Arguments: c.Function.Arguments, + }, + }) + } + return out +} + +// allIndexed reports whether every call carries a stream index, i.e. whether +// the order can be recovered from the indices alone. +func allIndexed(calls []schema.ToolCall) bool { + for _, c := range calls { + if c.Index == nil { + return false + } + } + return len(calls) > 0 +} diff --git a/internal/agent/eino_delta.go b/internal/agent/eino_delta.go new file mode 100644 index 0000000..e50aa18 --- /dev/null +++ b/internal/agent/eino_delta.go @@ -0,0 +1,70 @@ +package agent + +import ( + "context" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/components/model" + "github.com/cloudwego/eino/schema" +) + +// deltaHandler forwards the streamed assistant text to the panel as the model +// produces it. +// +// The text is taken from the model stream rather than from the agent event on +// purpose. The event carries a copy of the same stream, and the agent runs +// ahead of the event consumer: the tools of a turn start as soon as the +// agent has the model's answer. Deltas taken from the event could therefore +// reach the panel after the tool card of the very same turn, and the panel +// closes the assistant entry when a tool card arrives. Taking the text from +// the model stream keeps the order the user reads: the answer, then the tool. +type deltaHandler struct { + *adk.BaseChatModelAgentMiddleware + svc *Service + sessionID string + conv *conversation +} + +// WrapModel wraps the model so its output is reported while it streams. +func (h *deltaHandler) WrapModel(_ context.Context, m model.BaseModel[*schema.Message], _ *adk.ModelContext) (model.BaseModel[*schema.Message], error) { + return &deltaModel{ + inner: m, + svc: h.svc, + sessionID: h.sessionID, + conv: h.conv, + }, nil +} + +// deltaModel is the model the agent talks to: it reports the text of every +// frame it hands out. +type deltaModel struct { + inner model.BaseModel[*schema.Message] + svc *Service + sessionID string + conv *conversation +} + +// Generate returns the whole answer at once; the panel is fed by the +// streaming path. +func (m *deltaModel) Generate(ctx context.Context, in []*schema.Message, opts ...model.Option) (*schema.Message, error) { + return m.inner.Generate(ctx, in, opts...) +} + +// Stream reports every text frame as a delta before the frame reaches the +// agent. The reasoning text is kept in the transcript but never rendered: +// the panel shows the answer, not the model's thinking. +func (m *deltaModel) Stream(ctx context.Context, in []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) { + stream, err := m.inner.Stream(ctx, in, opts...) + if err != nil { + return nil, err + } + return schema.StreamReaderWithConvert(stream, func(msg *schema.Message) (*schema.Message, error) { + if msg != nil && msg.Content != "" { + m.svc.emitIfCurrent(m.sessionID, m.conv, EventDelta, DeltaEvent{ + SessionID: m.sessionID, + Delta: msg.Content, + }) + } + return msg, nil + }), nil +} diff --git a/internal/agent/eino_runner.go b/internal/agent/eino_runner.go new file mode 100644 index 0000000..4dab194 --- /dev/null +++ b/internal/agent/eino_runner.go @@ -0,0 +1,213 @@ +package agent + +import ( + "context" + "errors" + "io" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/compose" + "github.com/cloudwego/eino/schema" + + "nodeshell/internal/apperror" +) + +// einoAgentName identifies the agent inside the Eino runtime. It shows up in +// the run path of the framework's own events, never in the UI. +const einoAgentName = "nodeshell" + +// defaultMaxIterations is Eino's own iteration budget. It applies when +// Deps.MaxTurns is 0, i.e. when the caller does not pin a turn limit. +const defaultMaxIterations = 20 + +// runEino drives one accepted prompt through the Eino agent and returns the +// messages the run added to the transcript, in the order they were produced. +// +// The NodeShell transcript stays the source of truth: it is replayed to the +// agent as the model input, and whatever the agent produced is handed back so +// the caller can append it. +func (s *Service) runEino(ctx context.Context, sessionID, title string, cfg Config, c *conversation) ([]chatMessage, error) { + chatModel, err := s.model(ctx, cfg) + if err != nil { + return nil, err + } + history := toSchemaMessages(s.snapshot(c)) + + einoAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ + Name: einoAgentName, + Description: "The NodeShell in-app assistant for one SSH session.", + Instruction: systemPrompt(title), + Model: chatModel, + ToolsConfig: adk.ToolsConfig{ + ToolsNodeConfig: compose.ToolsNodeConfig{ + Tools: s.buildTools(sessionID, title, c), + // The tools act on one remote host, so they run one after + // another: parallel calls would interleave writes on the + // same session. + ExecuteSequentially: true, + // A hallucinated tool is a failed call, not a failed run: + // the model gets the refusal as text and can pick another + // tool instead of losing the whole answer. + UnknownToolsHandler: func(ctx context.Context, name, _ string) (string, error) { + s.emitIfCurrent(sessionID, c, EventTool, ToolEvent{ + SessionID: sessionID, + CallID: compose.GetToolCallID(ctx), + Name: name, + Summary: name, + OK: false, + Detail: "error: unknown tool", + }) + return "error: unknown tool", nil + }, + }, + }, + MaxIterations: s.maxIterations(), + Handlers: []adk.ChatModelAgentMiddleware{ + &callIDMiddleware{BaseChatModelAgentMiddleware: &adk.BaseChatModelAgentMiddleware{}}, + &historyCapHandler{BaseChatModelAgentMiddleware: &adk.BaseChatModelAgentMiddleware{}}, + &deltaHandler{ + BaseChatModelAgentMiddleware: &adk.BaseChatModelAgentMiddleware{}, + svc: s, + sessionID: sessionID, + conv: c, + }, + }, + }) + if err != nil { + return nil, errf(apperror.Unknown, "Agent could not be started") + } + + runner := adk.NewRunner(ctx, adk.RunnerConfig{ + Agent: einoAgent, + EnableStreaming: true, + }) + iter := runner.Run(ctx, history) + + var produced []chatMessage + for { + event, ok := iter.Next() + if !ok { + break + } + if event.Err != nil { + return produced, mapRunError(event.Err, cfg, s.maxIterations()) + } + if event.Output == nil || event.Output.MessageOutput == nil { + continue + } + msg, msgErr := consumeMessageOutput(event.Output.MessageOutput) + if msg != nil { + produced = append(produced, fromSchemaMessage(msg)) + } + if msgErr != nil { + return produced, mapRunError(msgErr, cfg, s.maxIterations()) + } + } + return produced, nil +} + +// buildTools binds the remote tools to the session the prompt named. +func (s *Service) buildTools(sessionID, title string, c *conversation) []tool.BaseTool { + defs := toolDefs() + out := make([]tool.BaseTool, 0, len(defs)) + for i := range defs { + out = append(out, &agentTool{ + svc: s, + def: &defs[i], + sessionID: sessionID, + title: title, + conv: c, + }) + } + return out +} + +// maxIterations is the iteration budget of one run: the pinned turn limit, or +// Eino's own default when the caller did not pin one. +func (s *Service) maxIterations() int { + if s.maxTurns > 0 { + return s.maxTurns + } + return defaultMaxIterations +} + +// historyCapHandler keeps the replayed transcript within the history cap, so +// a long tool run cannot grow the request without bound. The cap is applied +// to what the model sees; the NodeShell transcript keeps its own copy. +type historyCapHandler struct { + *adk.BaseChatModelAgentMiddleware +} + +// BeforeModelRewriteState trims the messages before every model call. +func (h *historyCapHandler) BeforeModelRewriteState(ctx context.Context, state *adk.ChatModelAgentState, _ *adk.ModelContext) (context.Context, *adk.ChatModelAgentState, error) { + state.Messages = trimStateMessages(state.Messages) + return ctx, state, nil +} + +// trimStateMessages caps the replayed transcript from the front. The system +// prompt stays: it is the agent's instruction, not part of the conversation +// the cap applies to. +func trimStateMessages(msgs []*schema.Message) []*schema.Message { + head := 0 + if len(msgs) > 0 && msgs[0].Role == schema.System { + head = 1 + } + if len(msgs)-head <= MaxHistoryMessages { + return msgs + } + tail := msgs[len(msgs)-MaxHistoryMessages:] + // A leading tool result is an orphan: the call it answers was cut away, + // and the endpoint rejects that. + for len(tail) > 0 && tail[0].Role == schema.Tool { + tail = tail[1:] + } + out := make([]*schema.Message, 0, head+len(tail)) + out = append(out, msgs[:head]...) + out = append(out, tail...) + return out +} + +// consumeMessageOutput turns one agent event into the message it carries. +// A streamed assistant turn is drained frame by frame, so the transcript +// keeps the whole turn and nothing is left unread in the framework. +func consumeMessageOutput(mv *adk.MessageVariant) (*schema.Message, error) { + if !mv.IsStreaming { + return mv.Message, nil + } + stream := mv.MessageStream + if stream == nil { + return nil, nil + } + defer stream.Close() + + var chunks []*schema.Message + for { + frame, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return concatChunks(chunks), err + } + chunks = append(chunks, frame) + } + return concatChunks(chunks), nil +} + +// concatChunks merges the frames of one streamed message. A frame that cannot +// be merged is dropped rather than failing the run: the text already reached +// the UI. +func concatChunks(chunks []*schema.Message) *schema.Message { + switch len(chunks) { + case 0: + return nil + case 1: + return chunks[0] + } + msg, err := schema.ConcatMessages(chunks) + if err != nil { + return chunks[len(chunks)-1] + } + return msg +} diff --git a/internal/agent/eino_tools.go b/internal/agent/eino_tools.go new file mode 100644 index 0000000..a03035c --- /dev/null +++ b/internal/agent/eino_tools.go @@ -0,0 +1,153 @@ +package agent + +import ( + "context" + "fmt" + + "github.com/cloudwego/eino/adk" + "github.com/cloudwego/eino/components/tool" + "github.com/cloudwego/eino/schema" +) + +// toolDef describes one model-facing tool: the name the model calls, the +// description it sees and the parameters it may pass. Schemas stay minimal on +// purpose: every value is validated again on execution, and the remote limits +// (exec output cap, file size cap) are enforced by the underlying MCP runtime. +type toolDef struct { + name string + desc string + params map[string]*schema.ParameterInfo +} + +// toolDefs is the advertised tool surface. There is deliberately no session +// argument: the session is injected from the panel the user is looking at, so +// the model cannot address another host, and no local-filesystem tool exists +// at all. +func toolDefs() []toolDef { + return []toolDef{ + { + name: toolBash, + desc: "Run a non-interactive shell command on the remote host over SSH. " + + "There is no TTY: commands cannot prompt, page or read stdin.", + params: map[string]*schema.ParameterInfo{ + "command": { + Type: schema.String, + Desc: "Shell command to run on the remote host", + Required: true, + }, + "timeoutMs": { + Type: schema.Integer, + Desc: "Optional timeout in milliseconds", + }, + }, + }, + { + name: toolSftpList, + desc: "List a directory on the remote host. Without a path, lists the session's current directory.", + params: map[string]*schema.ParameterInfo{ + "path": { + Type: schema.String, + Desc: "Remote directory path, absolute or relative to the current directory", + }, + }, + }, + { + name: toolSftpRead, + desc: fmt.Sprintf("Read a remote text file (max %d KiB).", MaxFileBytes/1024), + params: map[string]*schema.ParameterInfo{ + "path": { + Type: schema.String, + Desc: "Remote file path", + Required: true, + }, + }, + }, + { + name: toolSftpWrite, + desc: fmt.Sprintf("Write UTF-8 text to a remote file, replacing its contents (max %d KiB).", + MaxFileBytes/1024), + params: map[string]*schema.ParameterInfo{ + "path": { + Type: schema.String, + Desc: "Remote file path", + Required: true, + }, + "content": { + Type: schema.String, + Desc: "Full new file contents", + Required: true, + }, + }, + }, + } +} + +// agentTool exposes one remote tool to the Eino agent. It never fails the +// run: a failed call is handed back to the model as text, exactly like a +// failed command in a terminal. +type agentTool struct { + svc *Service + def *toolDef + sessionID string + title string + conv *conversation +} + +// Info advertises the tool to the model. +func (t *agentTool) Info(context.Context) (*schema.ToolInfo, error) { + return &schema.ToolInfo{ + Name: t.def.name, + Desc: t.def.desc, + ParamsOneOf: schema.NewParamsOneOfByParams(t.def.params), + }, nil +} + +// InvokableRun executes the tool against the bound session and returns the +// text the model sees. A failure is part of the answer, not the end of the +// run, so the error is returned as the tool result. +func (t *agentTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...tool.Option) (string, error) { + out, summary, ok := t.svc.invokeTool(ctx, t.sessionID, t.title, t.def.name, argumentsInJSON) + detail := "" + if !ok { + detail = firstLine(out) + } + t.svc.emitIfCurrent(t.sessionID, t.conv, EventTool, ToolEvent{ + SessionID: t.sessionID, + CallID: callIDFromContext(ctx), + Name: t.def.name, + Summary: summary, + OK: ok, + Detail: detail, + }) + return out, nil +} + +// callIDKey carries the model's tool-call id from the Eino tool middleware +// into the tool implementation, so the UI can pair a tool event with the +// call the model made. +type callIDKey struct{} + +// withCallID returns a context carrying the tool-call id. +func withCallID(ctx context.Context, id string) context.Context { + return context.WithValue(ctx, callIDKey{}, id) +} + +// callIDFromContext returns the tool-call id carried by the context. +func callIDFromContext(ctx context.Context) string { + id, _ := ctx.Value(callIDKey{}).(string) + return id +} + +// callIDMiddleware hands the tool-call id to the tool implementation, so the +// tool event can name the call the model made. +type callIDMiddleware struct { + *adk.BaseChatModelAgentMiddleware +} + +// WrapInvokableToolCall injects the call id into the context of the wrapped +// tool call. +func (m *callIDMiddleware) WrapInvokableToolCall(_ context.Context, endpoint adk.InvokableToolCallEndpoint, tCtx *adk.ToolContext) (adk.InvokableToolCallEndpoint, error) { + return func(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) { + return endpoint(withCallID(ctx, tCtx.CallID), argumentsInJSON, opts...) + }, nil +} diff --git a/internal/agent/errors.go b/internal/agent/errors.go new file mode 100644 index 0000000..3f19da4 --- /dev/null +++ b/internal/agent/errors.go @@ -0,0 +1,80 @@ +package agent + +import ( + "context" + "errors" + "net" + "net/url" + "strings" + + "github.com/cloudwego/eino-ext/components/model/openai" + "github.com/cloudwego/eino/adk" + + "nodeshell/internal/apperror" +) + +// mapRunError turns a failure of the Eino run into a coded error the UI can +// show. The provider message is kept because it is what makes a wrong model +// or a rejected key diagnosable, but it is bounded and the configured API key +// is redacted from it first. +func mapRunError(err error, cfg Config, maxIterations int) error { + if err == nil { + return nil + } + // A cancelled context is the user pressing stop, not a failure. + if errors.Is(err, context.Canceled) { + return context.Canceled + } + if errors.Is(err, context.DeadlineExceeded) { + return errf(apperror.Timeout, "Agent request timed out") + } + var apiErr *openai.APIError + if errors.As(err, &apiErr) { + return providerError(apiErr.HTTPStatusCode, apiErr.Message, cfg.APIKey) + } + // Eino stops the loop when the iteration budget is exhausted. + if errors.Is(err, adk.ErrExceedMaxIterations) || + strings.Contains(err.Error(), adk.ErrExceedMaxIterations.Error()) { + return errf(apperror.Unknown, "Agent stopped after %d steps without finishing", maxIterations) + } + // A transport failure that ran out of time is a timeout, not an outage. + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return errf(apperror.Timeout, "Agent request timed out") + } + // A transport failure says nothing about the model or the key, and the + // endpoint address is not worth putting on screen. + var urlErr *url.Error + if errors.As(err, &urlErr) { + return errf(apperror.Unknown, "Agent endpoint is unreachable") + } + return errf(apperror.Unknown, "Agent request failed: %s", sanitize(err.Error(), cfg.APIKey)) +} + +// providerError renders a provider rejection. The status code is kept so a +// wrong key (401/403) stays distinguishable from a bad request. +func providerError(status int, message, apiKey string) error { + text := sanitize(message, apiKey) + if text == "" { + if status == 401 || status == 403 { + return errf(apperror.Unknown, "Agent endpoint rejected the API key (HTTP %d)", status) + } + return errf(apperror.Unknown, "Agent endpoint returned HTTP %d", status) + } + return errf(apperror.Unknown, "Agent endpoint returned HTTP %d: %s", status, text) +} + +// sanitize bounds a provider message and removes the configured API key from +// it, so a server that echoes the credential cannot put it on screen. +func sanitize(message, apiKey string) string { + out := strings.TrimSpace(message) + if key := strings.TrimSpace(apiKey); key != "" { + out = strings.ReplaceAll(out, key, "[redacted]") + } + out = strings.Join(strings.Fields(out), " ") + const max = 300 + if len(out) > max { + return out[:max] + "…" + } + return out +} diff --git a/internal/agent/llm.go b/internal/agent/llm.go deleted file mode 100644 index 70c576c..0000000 --- a/internal/agent/llm.go +++ /dev/null @@ -1,320 +0,0 @@ -package agent - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "errors" - "io" - "net/http" - "strings" - - "nodeshell/internal/apperror" -) - -// Chat roles used in the transcript. -const ( - roleSystem = "system" - roleUser = "user" - roleAssistant = "assistant" - roleTool = "tool" -) - -// Stream limits. They bound one turn's memory independently of what the -// endpoint sends: a server that never stops streaming is cut off instead of -// growing the process heap. -const ( - maxSSELineBytes = 1 << 20 - maxAssistantTextBytes = 256 * 1024 - maxToolArgsBytes = 64 * 1024 - maxToolCallsPerTurn = 8 - maxErrorBodyBytes = 4 * 1024 -) - -// chatMessage is one OpenAI-compatible chat message. A tool result carries -// ToolCallID; an assistant turn that called tools carries ToolCalls. -type chatMessage struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - ToolCalls []toolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` -} - -type toolCall struct { - ID string `json:"id"` - Type string `json:"type"` - Function functionCall `json:"function"` -} - -type functionCall struct { - Name string `json:"name"` - Arguments string `json:"arguments"` -} - -// toolSpec is one advertised tool (OpenAI "function" tool shape). -type toolSpec struct { - Type string `json:"type"` - Function functionSpec `json:"function"` -} - -type functionSpec struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]any `json:"parameters"` -} - -type chatRequest struct { - Model string `json:"model"` - Messages []chatMessage `json:"messages"` - Tools []toolSpec `json:"tools,omitempty"` - Stream bool `json:"stream"` -} - -// streamChunk is one SSE payload. Only the fields the loop needs are decoded; -// everything else (usage, ids, fingerprints) is ignored. -type streamChunk struct { - Choices []struct { - Delta struct { - Content string `json:"content"` - ToolCalls []deltaToolCall `json:"tool_calls"` - } `json:"delta"` - } `json:"choices"` - Error *struct { - Message string `json:"message"` - } `json:"error"` -} - -// deltaToolCall is a tool call fragment: name and id arrive once, arguments -// arrive in pieces that must be concatenated per index. -type deltaToolCall struct { - Index int `json:"index"` - ID string `json:"id"` - Type string `json:"type"` - Function struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` -} - -// streamResult is one completed assistant turn. -type streamResult struct { - text string - toolCalls []toolCall -} - -// assistantMessage converts a finished turn into the transcript message. An -// assistant turn with tool calls must be recorded even when its text is -// empty, otherwise the following tool results have nothing to attach to. -func assistantMessage(res streamResult) chatMessage { - return chatMessage{Role: roleAssistant, Content: res.text, ToolCalls: res.toolCalls} -} - -// stream performs one streamed chat completion and returns the assistant text -// plus any tool calls. onDelta is invoked for each text fragment as it -// arrives, so the UI renders while the model is still writing. -func (s *Service) stream(ctx context.Context, cfg Config, msgs []chatMessage, onDelta func(string)) (streamResult, error) { - body, err := json.Marshal(chatRequest{ - Model: cfg.Model, - Messages: msgs, - Tools: toolSpecs(), - Stream: true, - }) - if err != nil { - return streamResult{}, errf(apperror.Unknown, "Agent request could not be built") - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, completionsURL(cfg.BaseURL), bytes.NewReader(body)) - if err != nil { - return streamResult{}, errf(apperror.Unknown, "Agent endpoint is invalid") - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "text/event-stream") - req.Header.Set("Authorization", "Bearer "+cfg.APIKey) - - resp, err := s.client.Do(req) - if err != nil { - return streamResult{}, requestError(ctx, err) - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode > 299 { - return streamResult{}, statusError(resp, cfg.APIKey) - } - return s.consume(ctx, resp.Body, cfg.APIKey, onDelta) -} - -// consume reads the SSE body, emitting text fragments and assembling tool -// calls. Every accumulator is bounded, and a cancelled context surfaces as -// the context error so the caller can tell an abort from a failure. -func (s *Service) consume(ctx context.Context, body io.Reader, apiKey string, onDelta func(string)) (streamResult, error) { - scanner := bufio.NewScanner(body) - scanner.Buffer(make([]byte, 0, 64*1024), maxSSELineBytes) - - var text strings.Builder - // Tool call fragments are keyed by the stream index, with order preserved - // separately: the API guarantees an index per call, not a stable order. - partials := make(map[int]*toolCall) - var order []int - - for scanner.Scan() { - if err := ctx.Err(); err != nil { - return streamResult{}, err - } - line := strings.TrimSuffix(scanner.Text(), "\r") - if line == "" || strings.HasPrefix(line, ":") { - continue - } - payload, ok := strings.CutPrefix(line, "data:") - if !ok { - // Any other SSE field (event, id, retry) carries no completion - // data for this protocol. - continue - } - payload = strings.TrimSpace(payload) - if payload == "[DONE]" { - break - } - var chunk streamChunk - if err := json.Unmarshal([]byte(payload), &chunk); err != nil { - // A single malformed chunk must not kill a run that is otherwise - // streaming fine. - continue - } - if chunk.Error != nil { - return streamResult{}, errf(apperror.Unknown, "Agent endpoint error: %s", - sanitize(chunk.Error.Message, apiKey)) - } - for _, choice := range chunk.Choices { - if delta := choice.Delta.Content; delta != "" { - if text.Len()+len(delta) > maxAssistantTextBytes { - return streamResult{}, errf(apperror.Unknown, "Agent response exceeded the size limit") - } - text.WriteString(delta) - if onDelta != nil { - onDelta(delta) - } - } - for _, frag := range choice.Delta.ToolCalls { - call, ok := partials[frag.Index] - if !ok { - if len(order) >= maxToolCallsPerTurn { - return streamResult{}, errf(apperror.Unknown, "Agent requested too many tools at once") - } - call = &toolCall{Type: "function"} - partials[frag.Index] = call - order = append(order, frag.Index) - } - if frag.ID != "" { - call.ID = frag.ID - } - if frag.Type != "" { - call.Type = frag.Type - } - if frag.Function.Name != "" { - call.Function.Name = frag.Function.Name - } - if args := frag.Function.Arguments; args != "" { - if len(call.Function.Arguments)+len(args) > maxToolArgsBytes { - return streamResult{}, errf(apperror.Unknown, "Agent tool arguments exceeded the size limit") - } - call.Function.Arguments += args - } - } - } - } - if err := scanner.Err(); err != nil { - if ctxErr := ctx.Err(); ctxErr != nil { - return streamResult{}, ctxErr - } - if errors.Is(err, bufio.ErrTooLong) { - return streamResult{}, errf(apperror.Unknown, "Agent response exceeded the size limit") - } - return streamResult{}, errf(apperror.Unknown, "Agent response stream failed") - } - - calls := make([]toolCall, 0, len(order)) - for _, idx := range order { - call := partials[idx] - // A fragment set that never named a function cannot be executed and - // would leave an unanswerable tool_call in the transcript. - if call.Function.Name == "" { - continue - } - if call.ID == "" { - call.ID = "call_" + strings.TrimSpace(call.Function.Name) - } - calls = append(calls, *call) - } - return streamResult{text: text.String(), toolCalls: calls}, nil -} - -// completionsURL appends the chat-completions path to the configured base -// URL. The base URL is expected to include the provider's version prefix -// (e.g. https://api.openai.com/v1), matching the OPENAI_BASE_URL convention. -func completionsURL(baseURL string) string { - return strings.TrimRight(strings.TrimSpace(baseURL), "/") + "/chat/completions" -} - -// requestError maps a transport failure. A cancelled or expired context wins -// over the transport message, which would otherwise report the cancellation -// as an opaque network error. -func requestError(ctx context.Context, err error) error { - if ctxErr := ctx.Err(); ctxErr != nil { - if errors.Is(ctxErr, context.DeadlineExceeded) { - return errf(apperror.Timeout, "Agent request timed out") - } - return ctxErr - } - return errf(apperror.Unknown, "Agent endpoint is unreachable") -} - -// statusError turns a non-2xx response into a coded error. The provider -// message is forwarded because it is what makes a wrong model or a rejected -// key diagnosable, but it is bounded and the configured key is redacted from -// it first. -func statusError(resp *http.Response, apiKey string) error { - raw, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) - message := providerMessage(raw) - if message == "" { - if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { - return errf(apperror.Unknown, "Agent endpoint rejected the API key (HTTP %d)", resp.StatusCode) - } - return errf(apperror.Unknown, "Agent endpoint returned HTTP %d", resp.StatusCode) - } - return errf(apperror.Unknown, "Agent endpoint returned HTTP %d: %s", resp.StatusCode, - sanitize(message, apiKey)) -} - -// providerMessage extracts the error text from the common OpenAI-compatible -// error envelopes, falling back to the raw body. -func providerMessage(raw []byte) string { - var envelope struct { - Error struct { - Message string `json:"message"` - } `json:"error"` - Message string `json:"message"` - } - if json.Unmarshal(raw, &envelope) == nil { - if envelope.Error.Message != "" { - return envelope.Error.Message - } - if envelope.Message != "" { - return envelope.Message - } - } - return strings.TrimSpace(string(raw)) -} - -// sanitize bounds a provider message and removes the configured API key from -// it, so a server that echoes the credential cannot put it on screen. -func sanitize(message, apiKey string) string { - out := strings.TrimSpace(message) - if key := strings.TrimSpace(apiKey); key != "" { - out = strings.ReplaceAll(out, key, "[redacted]") - } - out = strings.Join(strings.Fields(out), " ") - const max = 300 - if len(out) > max { - return out[:max] + "…" - } - return out -} diff --git a/internal/agent/model.go b/internal/agent/model.go new file mode 100644 index 0000000..b4a12af --- /dev/null +++ b/internal/agent/model.go @@ -0,0 +1,26 @@ +package agent + +import ( + "context" + "net/http" + + "github.com/cloudwego/eino-ext/components/model/openai" + "github.com/cloudwego/eino/components/model" +) + +// ModelFactory builds the chat model for one run. It is the seam the tests +// use: a factory returns a scripted model, so a test never reaches a +// provider. Production uses the OpenAI-compatible model built from Config. +type ModelFactory func(ctx context.Context, cfg Config) (model.ToolCallingChatModel, error) + +// newOpenAIModel builds the OpenAI-compatible chat model for one endpoint +// configuration. The API key only ever reaches the request header: it is not +// part of the model name, the base URL or any message. +func newOpenAIModel(ctx context.Context, cfg Config, client *http.Client) (model.ToolCallingChatModel, error) { + return openai.NewChatModel(ctx, &openai.ChatModelConfig{ + BaseURL: cfg.BaseURL, + Model: cfg.Model, + APIKey: cfg.APIKey, + HTTPClient: client, + }) +} diff --git a/internal/agent/tools.go b/internal/agent/tools.go index 5315477..d30a5d7 100644 --- a/internal/agent/tools.go +++ b/internal/agent/tools.go @@ -25,90 +25,13 @@ const ( // maxListEntries bounds one directory listing put into the transcript. const maxListEntries = 200 -// toolSpecs advertises the tool surface. Schemas stay minimal on purpose: -// every value is validated again on execution, and the remote limits (exec -// output cap, file size cap) are enforced by the underlying MCP runtime. -func toolSpecs() []toolSpec { - return []toolSpec{ - { - Type: "function", - Function: functionSpec{ - Name: toolBash, - Description: "Run a non-interactive shell command on the remote host over SSH. " + - "There is no TTY: commands cannot prompt, page or read stdin.", - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "command": map[string]any{ - "type": "string", - "description": "Shell command to run on the remote host", - }, - "timeoutMs": map[string]any{ - "type": "integer", - "description": "Optional timeout in milliseconds", - }, - }, - "required": []any{"command"}, - }, - }, - }, - { - Type: "function", - Function: functionSpec{ - Name: toolSftpList, - Description: "List a directory on the remote host. Without a path, lists the session's current directory.", - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "path": map[string]any{ - "type": "string", - "description": "Remote directory path, absolute or relative to the current directory", - }, - }, - }, - }, - }, - { - Type: "function", - Function: functionSpec{ - Name: toolSftpRead, - Description: fmt.Sprintf("Read a remote text file (max %d KiB).", MaxFileBytes/1024), - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "path": map[string]any{"type": "string", "description": "Remote file path"}, - }, - "required": []any{"path"}, - }, - }, - }, - { - Type: "function", - Function: functionSpec{ - Name: toolSftpWrite, - Description: fmt.Sprintf("Write UTF-8 text to a remote file, replacing its contents (max %d KiB).", - MaxFileBytes/1024), - Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "path": map[string]any{"type": "string", "description": "Remote file path"}, - "content": map[string]any{"type": "string", "description": "Full new file contents"}, - }, - "required": []any{"path", "content"}, - }, - }, - }, - } -} - -// runTool executes one tool call against the session. It returns the content -// to append to the transcript, a short display summary for the UI, and -// whether the call succeeded. A failure is never fatal to the run: the error -// text goes back to the model so it can correct itself, exactly like a failed -// command in a terminal. -func (s *Service) runTool(ctx context.Context, sessionID, title string, call toolCall) (string, string, bool) { - name := call.Function.Name - args, err := decodeArgsMap(call.Function.Arguments) +// invokeTool executes one tool call against the session. It returns the +// content to append to the transcript, a short display summary for the UI, +// and whether the call succeeded. A failure is never fatal to the run: the +// error text goes back to the model so it can correct itself, exactly like a +// failed command in a terminal. +func (s *Service) invokeTool(ctx context.Context, sessionID, title, name, argumentsJSON string) (string, string, bool) { + args, err := decodeArgsMap(argumentsJSON) if err != nil { return toolError(err), name, false } @@ -149,8 +72,8 @@ func (s *Service) commandTimeout(ms int64) time.Duration { return d } -// decodeArgsMap parses the streamed argument JSON. Empty arguments are treated -// as an empty object, which some providers send for a no-parameter call. +// decodeArgsMap parses the tool arguments. Empty arguments are treated as an +// empty object, which some providers send for a no-parameter call. func decodeArgsMap(raw string) (map[string]any, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { @@ -193,6 +116,7 @@ func stringFromArgs(args map[string]any, key string) string { return s } +// toolSummary is the short, display-ready line for one tool call. func toolSummary(name string, args map[string]any) string { switch name { case toolBash: @@ -210,6 +134,7 @@ func toolSummary(name string, args map[string]any) string { } } +// formatToolResult renders a successful tool result for the model. func formatToolResult(name string, args map[string]any, result any) string { switch name { case toolBash: From 1935241a612e769b21611f9941089407ed738a18 Mon Sep 17 00:00:00 2001 From: AzureLandin Date: Wed, 21 Oct 2026 15:06:55 +0800 Subject: [PATCH 4/5] feat: add agent context and reasoning settings Persist context window, thinking strength and context compression settings and expose them on the Agent settings page. --- internal/settings/settings.go | 204 +++++++++++++----- internal/settings/settings_test.go | 204 +++++++++++++++++- src/renderer/src/components/SettingsModal.tsx | 162 ++++++++++++++ src/renderer/src/i18n/locales/en.json | 13 ++ src/renderer/src/i18n/locales/zh.json | 13 ++ src/shared/types.ts | 22 ++ tests/ui/settings.test.tsx | 119 +++++++++- 7 files changed, 679 insertions(+), 58 deletions(-) diff --git a/internal/settings/settings.go b/internal/settings/settings.go index b99356f..6c737c9 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -52,6 +52,16 @@ type AppSettings struct { // picker's starting selection for a new SSH tab. AgentDefaultProviderID string `json:"agentDefaultProviderId"` AgentDefaultModel string `json:"agentDefaultModel"` + // AgentContextWindow is the estimated context budget (tokens) the agent + // runtime keeps for one conversation. 0 means "unlimited": the legacy + // behaviour where the provider's own model limit decides. + AgentContextWindow int `json:"agentContextWindow"` + // AgentThinkingStrength is the reasoning effort requested from the + // provider: auto (default), low, medium or high. + AgentThinkingStrength string `json:"agentThinkingStrength"` + // AgentContextCompression is off (default) or auto: whether the runtime + // may compact its own history when the context budget runs out. + AgentContextCompression string `json:"agentContextCompression"` // AgentProvidersPresent is true when agentProviders was in the file // (including an empty array). False means a pre-multi-provider file or a // missing file; Get then synthesises a legacy provider from the old @@ -79,6 +89,12 @@ var Defaults = AppSettings{ AgentModel: "gpt-4o-mini", PermissionPolicy: "ask", McpPermissionMode: string(permission.MCPModeExternal), + // Agent runtime defaults are conservative: no context budget of our own + // (0 = the provider's model limit applies), no forced reasoning effort + // and no automatic history compaction. + AgentContextWindow: 0, + AgentThinkingStrength: AgentThinkingAuto, + AgentContextCompression: AgentCompressionOff, } // Bounds from settings-store.ts. @@ -104,25 +120,45 @@ const ( LegacyProviderID = "legacy" // LegacyProviderName is the display name of that synthesised provider. LegacyProviderName = "Default" + // AgentContextWindowMin and AgentContextWindowMax bound the agent + // context budget in tokens. 0 is a valid value on its own and means + // "unlimited" (the provider's model limit applies). + AgentContextWindowMin = 4096 + AgentContextWindowMax = 1048576 + // AgentThinkingAuto lets the provider choose the reasoning effort. + AgentThinkingAuto = "auto" + // AgentThinkingLow and AgentThinkingMedium are explicit reasoning + // effort levels; AgentThinkingHigh requests the most thorough one. + AgentThinkingLow = "low" + AgentThinkingMedium = "medium" + AgentThinkingHigh = "high" + // AgentCompressionOff disables automatic context compaction. + AgentCompressionOff = "off" + // AgentCompressionAuto lets the runtime compact its own history when + // the context budget runs out. + AgentCompressionAuto = "auto" ) // Patch mirrors the Partial passed to settings.set; nil fields // are left unchanged. Numeric fields are floats so fractional input is // rounded exactly like the TS normalize functions. type Patch struct { - Language *string `json:"language"` - TerminalFontFamily *string `json:"terminalFontFamily"` - TerminalFontSize *float64 `json:"terminalFontSize"` - McpIdleTimeoutMinutes *float64 `json:"mcpIdleTimeoutMinutes"` - McpMaxSessions *float64 `json:"mcpMaxSessions"` - ThemePreference *string `json:"themePreference"` - AgentBaseURL *string `json:"agentBaseUrl"` - AgentModel *string `json:"agentModel"` - AgentProviders *[]AgentProvider `json:"agentProviders"` - AgentDefaultProviderID *string `json:"agentDefaultProviderId"` - AgentDefaultModel *string `json:"agentDefaultModel"` - PermissionPolicy *string `json:"permissionPolicy"` - McpPermissionMode *string `json:"mcpPermissionMode"` + Language *string `json:"language"` + TerminalFontFamily *string `json:"terminalFontFamily"` + TerminalFontSize *float64 `json:"terminalFontSize"` + McpIdleTimeoutMinutes *float64 `json:"mcpIdleTimeoutMinutes"` + McpMaxSessions *float64 `json:"mcpMaxSessions"` + ThemePreference *string `json:"themePreference"` + AgentBaseURL *string `json:"agentBaseUrl"` + AgentModel *string `json:"agentModel"` + AgentProviders *[]AgentProvider `json:"agentProviders"` + AgentDefaultProviderID *string `json:"agentDefaultProviderId"` + AgentDefaultModel *string `json:"agentDefaultModel"` + AgentContextWindow *float64 `json:"agentContextWindow"` + AgentThinkingStrength *string `json:"agentThinkingStrength"` + AgentContextCompression *string `json:"agentContextCompression"` + PermissionPolicy *string `json:"permissionPolicy"` + McpPermissionMode *string `json:"mcpPermissionMode"` } // Error carries the stable config error code the frontend maps onto @@ -141,19 +177,22 @@ func (e *Error) ErrorCode() string { return e.Code } // that would fail strict decoding (e.g. wrong-typed or non-numeric) still // reach the Number()/typeof-style coercion the TS normalize* functions apply. type fileSettings struct { - Language json.RawMessage `json:"language"` - TerminalFontFamily json.RawMessage `json:"terminalFontFamily"` - TerminalFontSize json.RawMessage `json:"terminalFontSize"` - McpIdleTimeoutMinutes json.RawMessage `json:"mcpIdleTimeoutMinutes"` - McpMaxSessions json.RawMessage `json:"mcpMaxSessions"` - ThemePreference json.RawMessage `json:"themePreference"` - AgentBaseURL json.RawMessage `json:"agentBaseUrl"` - AgentModel json.RawMessage `json:"agentModel"` - AgentProviders json.RawMessage `json:"agentProviders"` - AgentDefaultProviderID json.RawMessage `json:"agentDefaultProviderId"` - AgentDefaultModel json.RawMessage `json:"agentDefaultModel"` - PermissionPolicy json.RawMessage `json:"permissionPolicy"` - McpPermissionMode json.RawMessage `json:"mcpPermissionMode"` + Language json.RawMessage `json:"language"` + TerminalFontFamily json.RawMessage `json:"terminalFontFamily"` + TerminalFontSize json.RawMessage `json:"terminalFontSize"` + McpIdleTimeoutMinutes json.RawMessage `json:"mcpIdleTimeoutMinutes"` + McpMaxSessions json.RawMessage `json:"mcpMaxSessions"` + ThemePreference json.RawMessage `json:"themePreference"` + AgentBaseURL json.RawMessage `json:"agentBaseUrl"` + AgentModel json.RawMessage `json:"agentModel"` + AgentProviders json.RawMessage `json:"agentProviders"` + AgentDefaultProviderID json.RawMessage `json:"agentDefaultProviderId"` + AgentDefaultModel json.RawMessage `json:"agentDefaultModel"` + AgentContextWindow json.RawMessage `json:"agentContextWindow"` + AgentThinkingStrength json.RawMessage `json:"agentThinkingStrength"` + AgentContextCompression json.RawMessage `json:"agentContextCompression"` + PermissionPolicy json.RawMessage `json:"permissionPolicy"` + McpPermissionMode json.RawMessage `json:"mcpPermissionMode"` // rawWasObject is true when the file parsed as a JSON object. An array // top-level yields defaults and must not synthesise a legacy provider. rawWasObject bool @@ -214,6 +253,9 @@ func (s *Store) Set(patch Patch) (AppSettings, error) { mcpPermMode := current.McpPermissionMode providers := current.AgentProviders defaultID, defaultModel := current.AgentDefaultProviderID, current.AgentDefaultModel + contextWindow := float64(current.AgentContextWindow) + thinkingStrength := current.AgentThinkingStrength + compression := current.AgentContextCompression if patch.Language != nil { lang = *patch.Language } @@ -247,6 +289,15 @@ func (s *Store) Set(patch Patch) (AppSettings, error) { if patch.AgentDefaultModel != nil { defaultModel = *patch.AgentDefaultModel } + if patch.AgentContextWindow != nil { + contextWindow = *patch.AgentContextWindow + } + if patch.AgentThinkingStrength != nil { + thinkingStrength = *patch.AgentThinkingStrength + } + if patch.AgentContextCompression != nil { + compression = *patch.AgentContextCompression + } if patch.PermissionPolicy != nil { permPolicy = *patch.PermissionPolicy } @@ -255,20 +306,23 @@ func (s *Store) Set(patch Patch) (AppSettings, error) { } defaultID, defaultModel = normalizeAgentDefault(defaultID, defaultModel, providers) next := AppSettings{ - Language: normalizeLanguage(lang), - TerminalFontFamily: normalizeTerminalFontFamily(fam), - TerminalFontSize: normalizeTerminalFontSize(size), - McpIdleTimeoutMinutes: normalizeMcpIdleTimeoutMinutes(idle), - McpMaxSessions: normalizeMcpMaxSessions(max), - ThemePreference: normalizeThemePreference(theme), - AgentBaseURL: normalizeAgentBaseURL(agentURL), - AgentModel: normalizeAgentModel(agentModel), - AgentProviders: providers, - AgentDefaultProviderID: defaultID, - AgentDefaultModel: defaultModel, - AgentProvidersPresent: true, - PermissionPolicy: normalizePermissionPolicy(permPolicy), - McpPermissionMode: normalizeMcpPermissionMode(mcpPermMode), + Language: normalizeLanguage(lang), + TerminalFontFamily: normalizeTerminalFontFamily(fam), + TerminalFontSize: normalizeTerminalFontSize(size), + McpIdleTimeoutMinutes: normalizeMcpIdleTimeoutMinutes(idle), + McpMaxSessions: normalizeMcpMaxSessions(max), + ThemePreference: normalizeThemePreference(theme), + AgentBaseURL: normalizeAgentBaseURL(agentURL), + AgentModel: normalizeAgentModel(agentModel), + AgentProviders: providers, + AgentDefaultProviderID: defaultID, + AgentDefaultModel: defaultModel, + AgentProvidersPresent: true, + AgentContextWindow: normalizeAgentContextWindow(contextWindow), + AgentThinkingStrength: normalizeAgentThinkingStrength(thinkingStrength), + AgentContextCompression: normalizeAgentContextCompression(compression), + PermissionPolicy: normalizePermissionPolicy(permPolicy), + McpPermissionMode: normalizeMcpPermissionMode(mcpPermMode), } if next.AgentProviders == nil { next.AgentProviders = []AgentProvider{} @@ -576,6 +630,45 @@ func normalizePermissionPolicy(value string) string { } } +// normalizeAgentContextWindow keeps 0 (unlimited / legacy behaviour) and +// otherwise clamps the estimated token budget into +// [AgentContextWindowMin, AgentContextWindowMax]. A value that is not a +// number at all falls back to the default instead of clamping, matching how +// the other numeric settings behave. +func normalizeAgentContextWindow(value float64) int { + if !finite(value) { + return Defaults.AgentContextWindow + } + rounded := math.Round(value) + if rounded == 0 { + return 0 + } + return clampRound(AgentContextWindowMin, AgentContextWindowMax, rounded) +} + +// normalizeAgentThinkingStrength keeps only auto/low/medium/high; anything +// else falls back to auto so an unknown value never pins the runtime to a +// reasoning effort the provider does not know. +func normalizeAgentThinkingStrength(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case AgentThinkingLow, AgentThinkingMedium, AgentThinkingHigh, AgentThinkingAuto: + return strings.ToLower(strings.TrimSpace(value)) + default: + return Defaults.AgentThinkingStrength + } +} + +// normalizeAgentContextCompression keeps only off/auto; anything else falls +// back to off so history is never compacted behind the user's back. +func normalizeAgentContextCompression(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case AgentCompressionAuto: + return AgentCompressionAuto + default: + return Defaults.AgentContextCompression + } +} + // coerceString resolves a string field the way the TS normalize* functions // do: a JSON string wins; a missing field, null, number or boolean is not a // string, and each normalizer maps it to its default. @@ -710,20 +803,23 @@ func normalizeSettings(raw fileSettings) AppSettings { providers, ) return AppSettings{ - Language: normalizeLanguage(coerceString(raw.Language)), - TerminalFontFamily: normalizeTerminalFontFamily(coerceString(raw.TerminalFontFamily)), - TerminalFontSize: normalizeTerminalFontSize(coerceNumber(raw.TerminalFontSize)), - McpIdleTimeoutMinutes: normalizeMcpIdleTimeoutMinutes(coerceNumber(raw.McpIdleTimeoutMinutes)), - McpMaxSessions: normalizeMcpMaxSessions(coerceNumber(raw.McpMaxSessions)), - ThemePreference: normalizeThemePreference(coerceString(raw.ThemePreference)), - AgentBaseURL: url, - AgentModel: model, - AgentProviders: providers, - AgentDefaultProviderID: defaultID, - AgentDefaultModel: defaultModel, - AgentProvidersPresent: present, - PermissionPolicy: normalizePermissionPolicy(coerceString(raw.PermissionPolicy)), - McpPermissionMode: normalizeMcpPermissionMode(coerceString(raw.McpPermissionMode)), + Language: normalizeLanguage(coerceString(raw.Language)), + TerminalFontFamily: normalizeTerminalFontFamily(coerceString(raw.TerminalFontFamily)), + TerminalFontSize: normalizeTerminalFontSize(coerceNumber(raw.TerminalFontSize)), + McpIdleTimeoutMinutes: normalizeMcpIdleTimeoutMinutes(coerceNumber(raw.McpIdleTimeoutMinutes)), + McpMaxSessions: normalizeMcpMaxSessions(coerceNumber(raw.McpMaxSessions)), + ThemePreference: normalizeThemePreference(coerceString(raw.ThemePreference)), + AgentBaseURL: url, + AgentModel: model, + AgentProviders: providers, + AgentDefaultProviderID: defaultID, + AgentDefaultModel: defaultModel, + AgentProvidersPresent: present, + AgentContextWindow: normalizeAgentContextWindow(coerceNumber(raw.AgentContextWindow)), + AgentThinkingStrength: normalizeAgentThinkingStrength(coerceString(raw.AgentThinkingStrength)), + AgentContextCompression: normalizeAgentContextCompression(coerceString(raw.AgentContextCompression)), + PermissionPolicy: normalizePermissionPolicy(coerceString(raw.PermissionPolicy)), + McpPermissionMode: normalizeMcpPermissionMode(coerceString(raw.McpPermissionMode)), } } diff --git a/internal/settings/settings_test.go b/internal/settings/settings_test.go index 7de06dc..70bd00c 100644 --- a/internal/settings/settings_test.go +++ b/internal/settings/settings_test.go @@ -53,7 +53,10 @@ func TestGetFromFullFixture(t *testing.T) { BaseURL: "https://api.deepseek.com/v1", Models: []string{"deepseek-chat"}, }}, AgentDefaultProviderID: LegacyProviderID, AgentDefaultModel: "deepseek-chat", - PermissionPolicy: Defaults.PermissionPolicy, McpPermissionMode: Defaults.McpPermissionMode} + AgentContextWindow: Defaults.AgentContextWindow, + AgentThinkingStrength: Defaults.AgentThinkingStrength, + AgentContextCompression: Defaults.AgentContextCompression, + PermissionPolicy: Defaults.PermissionPolicy, McpPermissionMode: Defaults.McpPermissionMode} if !reflect.DeepEqual(got, want) { t.Fatalf("got %+v, want %+v", got, want) } @@ -122,7 +125,10 @@ func TestClampsValues(t *testing.T) { AgentBaseURL: Defaults.AgentBaseURL, AgentModel: Defaults.AgentModel, AgentProviders: []AgentProvider{synthesiseLegacyProvider(Defaults.AgentBaseURL, Defaults.AgentModel)}, AgentDefaultProviderID: LegacyProviderID, AgentDefaultModel: Defaults.AgentModel, - PermissionPolicy: Defaults.PermissionPolicy, McpPermissionMode: Defaults.McpPermissionMode} + AgentContextWindow: Defaults.AgentContextWindow, + AgentThinkingStrength: Defaults.AgentThinkingStrength, + AgentContextCompression: Defaults.AgentContextCompression, + PermissionPolicy: Defaults.PermissionPolicy, McpPermissionMode: Defaults.McpPermissionMode} if !reflect.DeepEqual(got, want) { t.Fatalf("clamp mismatch: got %+v want %+v", got, want) } @@ -243,7 +249,10 @@ func TestWrongTypedStringFieldsNormalizeLikeTS(t *testing.T) { AgentBaseURL: Defaults.AgentBaseURL, AgentModel: Defaults.AgentModel, AgentProviders: []AgentProvider{synthesiseLegacyProvider(Defaults.AgentBaseURL, Defaults.AgentModel)}, AgentDefaultProviderID: LegacyProviderID, AgentDefaultModel: Defaults.AgentModel, - PermissionPolicy: Defaults.PermissionPolicy, McpPermissionMode: Defaults.McpPermissionMode} + AgentContextWindow: Defaults.AgentContextWindow, + AgentThinkingStrength: Defaults.AgentThinkingStrength, + AgentContextCompression: Defaults.AgentContextCompression, + PermissionPolicy: Defaults.PermissionPolicy, McpPermissionMode: Defaults.McpPermissionMode} if !reflect.DeepEqual(got, want) { t.Fatalf("got %+v, want %+v (TS normalize* falls back to defaults)", got, want) } @@ -542,3 +551,192 @@ func isError(err error, out **Error) bool { } return ok } + +// The agent runtime knobs are global and conservative by default: no context +// budget of our own, no forced reasoning effort and no silent compaction. +func TestAgentRuntimeDefaults(t *testing.T) { + got, err := newStore(t).Get() + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.AgentContextWindow != 0 { + t.Fatalf("agentContextWindow = %d, want 0 (unlimited)", got.AgentContextWindow) + } + if got.AgentThinkingStrength != AgentThinkingAuto { + t.Fatalf("agentThinkingStrength = %q, want %q", got.AgentThinkingStrength, AgentThinkingAuto) + } + if got.AgentContextCompression != AgentCompressionOff { + t.Fatalf("agentContextCompression = %q, want %q", got.AgentContextCompression, AgentCompressionOff) + } + if Defaults.AgentContextWindow != 0 || + Defaults.AgentThinkingStrength != AgentThinkingAuto || + Defaults.AgentContextCompression != AgentCompressionOff { + t.Fatalf("Defaults = %+v, want the conservative agent runtime defaults", Defaults) + } +} + +// A file written before these fields existed still loads with the defaults. +func TestLegacyFileGetsAgentRuntimeDefaults(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(`{"language": "en"}`), 0o600); err != nil { + t.Fatal(err) + } + got, err := New(dir).Get() + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.AgentContextWindow != 0 || + got.AgentThinkingStrength != AgentThinkingAuto || + got.AgentContextCompression != AgentCompressionOff { + t.Fatalf("legacy file agent runtime = %+v, want the defaults", got) + } +} + +// The context window is 0 (unlimited) or clamped into [4096, 1048576]; a +// value that is not a number at all falls back to the default. +func TestAgentContextWindowNormalisation(t *testing.T) { + cases := []struct { + name string + raw string + want int + }{ + {"zero means unlimited", `{"agentContextWindow": 0}`, 0}, + {"inside the range is kept", `{"agentContextWindow": 32768}`, 32768}, + {"lower bound is kept", `{"agentContextWindow": 4096}`, 4096}, + {"upper bound is kept", `{"agentContextWindow": 1048576}`, 1048576}, + {"below the range clamps up", `{"agentContextWindow": 100}`, 4096}, + {"negative clamps up", `{"agentContextWindow": -1}`, 4096}, + {"above the range clamps down", `{"agentContextWindow": 99999999}`, 1048576}, + {"fractional rounds", `{"agentContextWindow": 8192.6}`, 8193}, + {"numeric string is coerced", `{"agentContextWindow": "16384"}`, 16384}, + {"not a number falls back", `{"agentContextWindow": "abc"}`, 0}, + {"wrong type falls back", `{"agentContextWindow": {}}`, 0}, + {"absent falls back", `{}`, 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(tc.raw), 0o600); err != nil { + t.Fatal(err) + } + got, err := New(dir).Get() + if err != nil { + t.Fatalf("Get(%s): %v", tc.raw, err) + } + if got.AgentContextWindow != tc.want { + t.Fatalf("agentContextWindow from %s = %d, want %d", tc.raw, got.AgentContextWindow, tc.want) + } + }) + } +} + +// Unknown enum values fall back safely instead of pinning the runtime to a +// mode the provider does not know. +func TestAgentRuntimeEnumNormalisation(t *testing.T) { + cases := []struct { + raw string + wantStrength string + wantCompression string + }{ + {`{}`, AgentThinkingAuto, AgentCompressionOff}, + {`{"agentThinkingStrength": "auto"}`, AgentThinkingAuto, AgentCompressionOff}, + {`{"agentThinkingStrength": "low"}`, AgentThinkingLow, AgentCompressionOff}, + {`{"agentThinkingStrength": "medium"}`, AgentThinkingMedium, AgentCompressionOff}, + {`{"agentThinkingStrength": "high"}`, AgentThinkingHigh, AgentCompressionOff}, + {`{"agentContextCompression": "auto"}`, AgentThinkingAuto, AgentCompressionAuto}, + {`{"agentThinkingStrength": "ultra"}`, AgentThinkingAuto, AgentCompressionOff}, + {`{"agentThinkingStrength": 7}`, AgentThinkingAuto, AgentCompressionOff}, + {`{"agentContextCompression": "always"}`, AgentThinkingAuto, AgentCompressionOff}, + {`{"agentContextCompression": true}`, AgentThinkingAuto, AgentCompressionOff}, + } + for _, tc := range cases { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(tc.raw), 0o600); err != nil { + t.Fatal(err) + } + got, err := New(dir).Get() + if err != nil { + t.Fatalf("Get(%s): %v", tc.raw, err) + } + if got.AgentThinkingStrength != tc.wantStrength { + t.Fatalf("agentThinkingStrength from %s = %q, want %q", tc.raw, got.AgentThinkingStrength, tc.wantStrength) + } + if got.AgentContextCompression != tc.wantCompression { + t.Fatalf("agentContextCompression from %s = %q, want %q", tc.raw, got.AgentContextCompression, tc.wantCompression) + } + } +} + +// Set normalises the agent runtime fields exactly like Get does. +func TestSetNormalisesAgentRuntimeFields(t *testing.T) { + store := newStore(t) + strength := "ultra" + compression := "always" + got, err := store.Set(Patch{ + AgentContextWindow: f64p(100), + AgentThinkingStrength: &strength, + AgentContextCompression: &compression, + }) + if err != nil { + t.Fatalf("Set: %v", err) + } + if got.AgentContextWindow != AgentContextWindowMin { + t.Fatalf("agentContextWindow = %d, want %d", got.AgentContextWindow, AgentContextWindowMin) + } + if got.AgentThinkingStrength != AgentThinkingAuto { + t.Fatalf("agentThinkingStrength = %q, want %q", got.AgentThinkingStrength, AgentThinkingAuto) + } + if got.AgentContextCompression != AgentCompressionOff { + t.Fatalf("agentContextCompression = %q, want %q", got.AgentContextCompression, AgentCompressionOff) + } +} + +// A partial patch must only touch the field it carries: the other agent +// runtime fields and the unrelated settings survive unchanged. +func TestPartialAgentRuntimePatchKeepsOtherFields(t *testing.T) { + store := newStore(t) + strength := "medium" + compression := "auto" + if _, err := store.Set(Patch{ + AgentContextWindow: f64p(32768), + AgentThinkingStrength: &strength, + AgentContextCompression: &compression, + }); err != nil { + t.Fatalf("Set: %v", err) + } + + onlyWindow := f64p(65536) + got, err := store.Set(Patch{AgentContextWindow: onlyWindow}) + if err != nil { + t.Fatalf("Set: %v", err) + } + if got.AgentContextWindow != 65536 { + t.Fatalf("agentContextWindow = %d, want 65536", got.AgentContextWindow) + } + if got.AgentThinkingStrength != AgentThinkingMedium { + t.Fatalf("agentThinkingStrength = %q, want %q (a partial patch must not clobber it)", + got.AgentThinkingStrength, AgentThinkingMedium) + } + if got.AgentContextCompression != AgentCompressionAuto { + t.Fatalf("agentContextCompression = %q, want %q (a partial patch must not clobber it)", + got.AgentContextCompression, AgentCompressionAuto) + } + if got.Language != Defaults.Language || got.TerminalFontSize != Defaults.TerminalFontSize { + t.Fatalf("unrelated settings changed: %+v", got) + } + + // The persisted file keeps every field, not only the patched one. + raw, err := os.ReadFile(filepath.Join(store.dir, "settings.json")) + if err != nil { + t.Fatal(err) + } + var file map[string]any + if err := json.Unmarshal(raw, &file); err != nil { + t.Fatalf("persisted JSON invalid: %v (%q)", err, raw) + } + if file["agentContextWindow"] != float64(65536) || + file["agentThinkingStrength"] != AgentThinkingMedium || + file["agentContextCompression"] != AgentCompressionAuto { + t.Fatalf("persisted agent runtime mismatch: %v", file) + } +} diff --git a/src/renderer/src/components/SettingsModal.tsx b/src/renderer/src/components/SettingsModal.tsx index b88f37e..b7d8acb 100644 --- a/src/renderer/src/components/SettingsModal.tsx +++ b/src/renderer/src/components/SettingsModal.tsx @@ -2,7 +2,10 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import type { AgentConfigStatus, + AgentContextCompression, AgentProviderStatus, + AgentThinkingStrength, + AppSettings, LanguageCode, McpManualConfig, McpPermissionMode, @@ -17,6 +20,32 @@ import { Select } from './Select' const MCP_SNIPPET_FORMATS: McpSnippetFormat[] = ['standard', 'vscode', 'opencode', 'codex'] +/** Lower bound of the agent context budget in tokens (0 = unlimited). */ +const AGENT_CONTEXT_WINDOW_MIN = 4096 +/** Upper bound of the agent context budget in tokens. */ +const AGENT_CONTEXT_WINDOW_MAX = 1048576 + +/** + * Clamp the estimated context budget the same way the settings store does: + * 0 stays "unlimited", everything else is clamped into + * [AGENT_CONTEXT_WINDOW_MIN, AGENT_CONTEXT_WINDOW_MAX]. + */ +function clampAgentContextWindow(value: number): number { + if (!Number.isFinite(value)) return 0 + const rounded = Math.round(value) + if (rounded === 0) return 0 + return Math.min(AGENT_CONTEXT_WINDOW_MAX, Math.max(AGENT_CONTEXT_WINDOW_MIN, rounded)) +} + +function normalizeThinkingStrength(value: string | undefined): AgentThinkingStrength { + if (value === 'low' || value === 'medium' || value === 'high') return value + return 'auto' +} + +function normalizeContextCompression(value: string | undefined): AgentContextCompression { + return value === 'auto' ? 'auto' : 'off' +} + function formatMcpLaunchCommand(command: string, args: string[]): string { const quote = (s: string): string => { if (s === '') return '""' @@ -362,6 +391,138 @@ function newestProvider( return providers.find((p) => !known.has(p.id)) ?? providers[providers.length - 1] } +/** + * Agent runtime settings. These are global (not per provider), so they live + * in settings.json and are read and written through the existing settings + * API — the provider CRUD below stays untouched. + */ +function AgentRuntimeSettingsSection(): React.JSX.Element { + const { t } = useTranslation() + const [contextWindow, setContextWindow] = useState('0') + const [savedContextWindow, setSavedContextWindow] = useState(0) + const [thinkingStrength, setThinkingStrength] = useState('auto') + const [compression, setCompression] = useState('off') + const [ready, setReady] = useState(false) + const [busy, setBusy] = useState(false) + const [message, setMessage] = useState(null) + + useEffect(() => { + void (async () => { + try { + const settings = await window.api.settings.get() + const tokens = settings?.agentContextWindow ?? 0 + setSavedContextWindow(tokens) + setContextWindow(String(tokens)) + setThinkingStrength(normalizeThinkingStrength(settings?.agentThinkingStrength)) + setCompression(normalizeContextCompression(settings?.agentContextCompression)) + } catch (err) { + setMessage(err instanceof Error ? err.message : String(err)) + } finally { + setReady(true) + } + })() + }, []) + + /** Persist one field; returns false when the backend rejected the write. */ + const persist = async (patch: Partial): Promise => { + setBusy(true) + setMessage(null) + try { + await window.api.settings.set(patch) + setMessage(t('settings.agentRuntimeSaved')) + return true + } catch (err) { + setMessage(err instanceof Error ? err.message : String(err)) + return false + } finally { + setBusy(false) + } + } + + const commitContextWindow = async (): Promise => { + const raw = contextWindow.trim() + const parsed = raw === '' ? Number.NaN : Number(raw) + if (!Number.isFinite(parsed)) { + setContextWindow(String(savedContextWindow)) + return + } + const next = clampAgentContextWindow(parsed) + if (next === savedContextWindow) { + setContextWindow(String(next)) + return + } + const saved = await persist({ agentContextWindow: next }) + setSavedContextWindow(saved ? next : savedContextWindow) + setContextWindow(String(saved ? next : savedContextWindow)) + } + + return ( +
+ {t('settings.agentRuntime')} +

{t('settings.agentRuntimeHint')}

+ +
+ {t('settings.agentContextWindow')} + setContextWindow(e.target.value)} + onBlur={() => void commitContextWindow()} + onKeyDown={(e) => { + if (e.key === 'Enter') void commitContextWindow() + }} + /> +
+

{t('settings.agentContextWindowHint')}

+ +
+ {t('settings.agentThinkingStrength')} + { + const next = normalizeContextCompression(v) + setCompression(next) + void persist({ agentContextCompression: next }) + }} + options={[ + { value: 'off', label: t('settings.agentCompressionOff') }, + { value: 'auto', label: t('settings.agentCompressionAuto') } + ]} + /> +
+ + {message &&

{message}

} +
+ ) +} + type SettingsPage = 'index' | 'general' | 'agent' | 'mcp' function GeneralSettingsSection({ @@ -906,6 +1067,7 @@ function SettingsModalBody({ ) : page === 'agent' ? (
+
) : ( = {}): AppSettings { + return { + language: 'en', + themePreference: 'system', + terminalFontFamily: 'Hack', + terminalFontSize: 14, + mcpIdleTimeoutMinutes: 10, + mcpMaxSessions: 8, + agentContextWindow: 0, + agentThinkingStrength: 'auto', + agentContextCompression: 'off', + ...overrides + } +} + describe('SettingsModal navigation and motion', () => { it('index lists only the three category rows', () => { const fake = installFakeApi() @@ -555,3 +571,104 @@ describe('SettingsModal permissions', () => { expect(props.onPermissionPolicyChange).toHaveBeenCalledWith('deny') }) }) + +describe('SettingsModal agent runtime', () => { + it('renders the runtime controls with the persisted values', async () => { + const fake = installFakeApi() + fake.mocks.settings.get.mockResolvedValue( + persistedSettings({ + agentContextWindow: 32768, + agentThinkingStrength: 'medium', + agentContextCompression: 'auto' + }) + ) + renderWithI18n() + const user = userEvent.setup() + await openCategory(user, 'Agent') + + const contextWindow = await screen.findByLabelText('Context window (estimated tokens)') + await waitFor(() => expect(contextWindow).toBeEnabled()) + expect(contextWindow).toHaveValue(32768) + expect(screen.getByLabelText('Thinking strength')).toHaveTextContent('Medium') + expect(screen.getByLabelText('Context compression')).toHaveTextContent('Automatic') + expect(screen.getByText(/0 means unlimited/)).toBeInTheDocument() + }) + + it('falls back to the defaults when the persisted enum values are unknown', async () => { + const fake = installFakeApi() + fake.mocks.settings.get.mockResolvedValue( + persistedSettings({ + agentThinkingStrength: 'ultra' as AppSettings['agentThinkingStrength'], + agentContextCompression: 'always' as AppSettings['agentContextCompression'] + }) + ) + renderWithI18n() + const user = userEvent.setup() + await openCategory(user, 'Agent') + + const contextWindow = await screen.findByLabelText('Context window (estimated tokens)') + await waitFor(() => expect(contextWindow).toBeEnabled()) + expect(screen.getByLabelText('Thinking strength')).toHaveTextContent('Automatic') + expect(screen.getByLabelText('Context compression')).toHaveTextContent('Off') + }) + + it('saves the context window through the settings API', async () => { + const fake = installFakeApi() + fake.mocks.settings.get.mockResolvedValue(persistedSettings()) + fake.mocks.settings.set.mockResolvedValue(persistedSettings()) + renderWithI18n() + const user = userEvent.setup() + await openCategory(user, 'Agent') + + const contextWindow = await screen.findByLabelText('Context window (estimated tokens)') + await waitFor(() => expect(contextWindow).toBeEnabled()) + await user.clear(contextWindow) + await user.type(contextWindow, '65536') + await user.tab() + + await waitFor(() => + expect(fake.mocks.settings.set).toHaveBeenCalledWith({ agentContextWindow: 65536 }) + ) + }) + + it('saves the thinking strength and the compression mode', async () => { + const fake = installFakeApi() + fake.mocks.settings.get.mockResolvedValue(persistedSettings()) + fake.mocks.settings.set.mockResolvedValue(persistedSettings()) + renderWithI18n() + const user = userEvent.setup() + await openCategory(user, 'Agent') + + await waitFor(() => expect(screen.getByLabelText('Thinking strength')).toBeEnabled()) + await user.click(screen.getByLabelText('Thinking strength')) + await user.click(await screen.findByRole('option', { name: 'High' })) + await waitFor(() => + expect(fake.mocks.settings.set).toHaveBeenCalledWith({ agentThinkingStrength: 'high' }) + ) + + await user.click(screen.getByLabelText('Context compression')) + await user.click(await screen.findByRole('option', { name: 'Automatic' })) + await waitFor(() => + expect(fake.mocks.settings.set).toHaveBeenCalledWith({ agentContextCompression: 'auto' }) + ) + }) + + it('clamps a context window below the minimum before saving it', async () => { + const fake = installFakeApi() + fake.mocks.settings.get.mockResolvedValue(persistedSettings()) + fake.mocks.settings.set.mockResolvedValue(persistedSettings()) + renderWithI18n() + const user = userEvent.setup() + await openCategory(user, 'Agent') + + const contextWindow = await screen.findByLabelText('Context window (estimated tokens)') + await waitFor(() => expect(contextWindow).toBeEnabled()) + await user.clear(contextWindow) + await user.type(contextWindow, '100') + await user.tab() + + await waitFor(() => + expect(fake.mocks.settings.set).toHaveBeenCalledWith({ agentContextWindow: 4096 }) + ) + }) +}) From 52965270b539e16fbc9a390c22477193d47887c6 Mon Sep 17 00:00:00 2001 From: AzureLandin Date: Wed, 21 Oct 2026 15:35:44 +0800 Subject: [PATCH 5/5] feat: wire agent runtime settings and context compression Pass context window, thinking strength and compression mode from settings into the Eino agent, and compress oversized histories with summarisation plus deterministic fallback. --- internal/agent/agent.go | 28 +- internal/agent/agent_test.go | 79 ++++- internal/agent/context.go | 441 ++++++++++++++++++++++++++++ internal/agent/context_test.go | 506 ++++++++++++++++++++++++++++++++ internal/agent/eino_runner.go | 48 ++- internal/agent/errors.go | 29 ++ internal/agent/model.go | 26 +- internal/agent/thinking_test.go | 240 +++++++++++++++ internal/app/app.go | 9 +- internal/app/app_agent_test.go | 99 +++++++ 10 files changed, 1493 insertions(+), 12 deletions(-) create mode 100644 internal/agent/context.go create mode 100644 internal/agent/context_test.go create mode 100644 internal/agent/thinking_test.go diff --git a/internal/agent/agent.go b/internal/agent/agent.go index e13b7f1..1499554 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -7,8 +7,10 @@ // // The package deliberately implements only the loop (prompt -> stream -> // tools -> stream) and none of the extras a full coding agent ships: no local -// filesystem access, no skills or extensions, no sub-agents and no context -// compaction beyond a plain transcript cap. +// filesystem access, no skills or extensions and no sub-agents. It does keep +// one conversation inside a context window: when the user configured a +// budget, the older turns are compacted into a summary before the request +// leaves NodeShell. package agent import ( @@ -72,6 +74,16 @@ type Config struct { BaseURL string Model string APIKey string + // ContextWindow is the estimated token budget of one conversation. + // 0 means unlimited: the provider's own model limit applies and the + // runtime never compacts the transcript. + ContextWindow int + // ThinkingStrength is the reasoning effort asked of the provider: + // auto (the provider decides), low, medium or high. + ThinkingStrength string + // ContextCompression is off (no automatic compaction) or auto (the + // runtime compacts the transcript when the budget runs out). + ContextCompression string } // ErrNotConfigured is the pre-flight rejection for a missing API key or @@ -256,6 +268,14 @@ func (s *Service) Prompt(sessionID, title, text string, cfg Config) error { strings.TrimSpace(cfg.BaseURL) == "" { return ErrNotConfigured } + // A prompt that cannot fit the context window is rejected like an + // oversized one: no compaction can make room for it. + if contextLimit(cfg.ContextWindow) > 0 { + own := []chatMessage{{Role: roleUser, Content: prompt}} + if estimateContextTokens(systemPrompt(title), own) > contextLimit(cfg.ContextWindow) { + return errf(apperror.Unknown, "Prompt does not fit the context window") + } + } s.mu.Lock() if s.closed { @@ -392,7 +412,11 @@ func (s *Service) run(ctx context.Context, cancel context.CancelFunc, sessionID, // back into the NodeShell transcript. The transcript stays the source of // truth: it is replayed to the agent, and the agent's assistant and tool // messages are appended to it afterwards. +// +// A transcript that outgrows the configured context window is compacted +// first, so the request that leaves NodeShell fits the provider's window. func (s *Service) loop(ctx context.Context, sessionID, title string, cfg Config, c *conversation) error { + s.prepareContext(ctx, sessionID, title, cfg, c) produced, err := s.runEino(ctx, sessionID, title, cfg, c) s.appendAll(c, produced) return err diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index bd2e3cf..8db7fdf 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -137,9 +137,36 @@ type fakeModel struct { requests [][]*schema.Message // tools holds the tool schemas the agent advertised. tools []*schema.ToolInfo + // summaries answers the non-streaming calls. The context compaction + // asks for its summary that way; the agent itself always streams. + summaries []string + // summaryErr fails the non-streaming call. + summaryErr error + // summaryRequests holds the messages of every non-streaming call. + summaryRequests [][]*schema.Message } +// Generate answers the non-streaming calls. The agent itself always streams, +// so this is the path the context compaction takes. func (f *fakeModel) Generate(ctx context.Context, in []*schema.Message, opts ...model.Option) (*schema.Message, error) { + f.mu.Lock() + queued := len(f.summaries) > 0 + text := "" + if queued { + text = f.summaries[0] + f.summaries = f.summaries[1:] + } + err := f.summaryErr + if queued || err != nil { + f.summaryRequests = append(f.summaryRequests, in) + } + f.mu.Unlock() + if err != nil { + return nil, err + } + if queued { + return schema.AssistantMessage(text, nil), nil + } turn, err := f.next(in, opts) if err != nil { return nil, err @@ -237,6 +264,27 @@ func (f *fakeModel) advertisedTools() []*schema.ToolInfo { return f.tools } +// setSummaries scripts the answers of the compaction calls. +func (f *fakeModel) setSummaries(s ...string) { + f.mu.Lock() + defer f.mu.Unlock() + f.summaries = s +} + +// failSummary makes the compaction call fail. +func (f *fakeModel) failSummary(err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.summaryErr = err +} + +// summaryCalls returns the messages of the compaction calls. +func (f *fakeModel) summaryCalls() [][]*schema.Message { + f.mu.Lock() + defer f.mu.Unlock() + return append([][]*schema.Message(nil), f.summaryRequests...) +} + // --- fake tools --- type toolCallRec struct { @@ -347,6 +395,12 @@ type harness struct { sink *recSink tools *fakeTools model *fakeModel + + mu sync.Mutex + // configs holds the configuration every model factory call saw, in + // order: the run itself and, when the transcript is compacted, the + // summary request. + configs []Config } func newHarness(t *testing.T, turns []fakeTurn, tweak func(*Deps)) *harness { @@ -359,7 +413,10 @@ func newHarness(t *testing.T, turns []fakeTurn, tweak func(*Deps)) *harness { deps := Deps{ Tools: h.tools, Sink: h.sink, - Model: func(context.Context, Config) (model.ToolCallingChatModel, error) { + Model: func(_ context.Context, cfg Config) (model.ToolCallingChatModel, error) { + h.mu.Lock() + h.configs = append(h.configs, cfg) + h.mu.Unlock() return h.model, nil }, RequestTimeout: 5 * time.Second, @@ -373,6 +430,13 @@ func newHarness(t *testing.T, turns []fakeTurn, tweak func(*Deps)) *harness { return h } +// seenConfigs returns the configurations the model factory was called with. +func (h *harness) seenConfigs() []Config { + h.mu.Lock() + defer h.mu.Unlock() + return append([]Config(nil), h.configs...) +} + func (h *harness) cfg() Config { return Config{BaseURL: "https://model.test/v1", Model: "test-model", APIKey: "sk-test-key"} } @@ -381,6 +445,19 @@ func (h *harness) prompt(sessionID, title, text string) error { return h.svc.Prompt(sessionID, title, text, h.cfg()) } +// seedHistory puts a transcript into a session, so a test can start from a +// conversation that already fills the context window. +func (h *harness) seedHistory(sessionID string, msgs []chatMessage) { + h.svc.mu.Lock() + defer h.svc.mu.Unlock() + c := h.svc.convs[sessionID] + if c == nil { + c = &conversation{} + h.svc.convs[sessionID] = c + } + c.messages = append(c.messages, msgs...) +} + // history returns the transcript of a session. func (h *harness) history(sessionID string) []chatMessage { h.svc.mu.Lock() diff --git a/internal/agent/context.go b/internal/agent/context.go new file mode 100644 index 0000000..84e86a5 --- /dev/null +++ b/internal/agent/context.go @@ -0,0 +1,441 @@ +package agent + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/cloudwego/eino/schema" +) + +// Context budgeting. +// +// The numbers here are deliberately coarse. They do not predict the +// provider's tokenizer: they only decide when the transcript has grown so +// large that the next request would not fit, and how much of it may be kept +// verbatim afterwards. +// +// The estimate is characters/4, plus a fixed reserve for the tool schemas +// the agent advertises to the model. A window of 0 means "unlimited": the +// provider's own model limit decides and NodeShell never compacts anything. + +// Thinking strengths and compression modes. They mirror the values the +// settings store persists. +const ( + // ThinkingAuto leaves the reasoning effort to the provider. + ThinkingAuto = "auto" + // ThinkingLow, ThinkingMedium and ThinkingHigh ask for a reasoning + // effort explicitly. + ThinkingLow = "low" + ThinkingMedium = "medium" + ThinkingHigh = "high" + + // CompressionOff disables the automatic compaction. + CompressionOff = "off" + // CompressionAuto lets the runtime compact its own transcript. + CompressionAuto = "auto" +) + +const ( + // charsPerToken is the rough characters-per-token ratio of the + // estimator: four characters are about one token. + charsPerToken = 4 + // toolSchemaReserveTokens is the room kept for the tool schemas the + // agent advertises to the model on every request. + toolSchemaReserveTokens = 1000 + // compressionThreshold is the share of the context window at which the + // transcript is compacted. + compressionThreshold = 0.8 + // summaryBudgetFraction is the share of the context window one summary + // may occupy. + summaryBudgetFraction = 0.2 + // keepRecentMessages is the number of recent messages kept verbatim + // when the transcript is compacted. + keepRecentMessages = 8 + // keepToolRounds is the number of recent complete tool rounds kept + // verbatim when the transcript is compacted. + keepToolRounds = 2 + // summaryTimeout bounds one summarisation request. A summary that runs + // out of time is not fatal: the caller falls back to truncation. + summaryTimeout = 60 * time.Second + // summaryPrefix marks a compacted prefix inside the transcript. + summaryPrefix = "[Conversation summary]" +) + +// summaryInstruction asks the model for a summary that keeps the facts a +// later turn may still need. +const summaryInstruction = "You are compacting an SSH assistant conversation so the older turns can be replaced by a short summary. Keep, in this order: the purpose of the session and the host it works on, the important commands that were run and what they showed, the files that were read or written, the decisions that were made, the tasks that are still pending, the errors and limits that were hit, and the preferences the user stated. Answer with the summary only, as terse bullet points." + +// estimateTokens estimates the tokens of a text. The ratio is rough on +// purpose: the estimate only decides when to compact. +func estimateTokens(chars int) int { + if chars <= 0 { + return 0 + } + return (chars + charsPerToken - 1) / charsPerToken +} + +// estimateContextTokens estimates the tokens one request would take: the +// instruction, the transcript and the tool schemas. +func estimateContextTokens(instruction string, msgs []chatMessage) int { + chars := len(instruction) + for _, m := range msgs { + chars += len(m.Content) + chars += len(m.ReasoningContent) + for _, call := range m.ToolCalls { + chars += len(call.Function.Name) + len(call.Function.Arguments) + } + } + return estimateTokens(chars) + toolSchemaReserveTokens +} + +// contextLimit is the number of tokens a conversation may use before the +// runtime compacts it. A window of 0 disables the budget entirely. +func contextLimit(window int) int { + if window <= 0 { + return 0 + } + return window +} + +// compressionEnabled reports whether the runtime may compact the +// transcript. It needs both a budget and the compression turned on: without +// one of them the plain history cap stays the only limit. +func compressionEnabled(cfg Config) bool { + if contextLimit(cfg.ContextWindow) == 0 { + return false + } + switch cfg.ContextCompression { + case CompressionAuto: + return true + case CompressionOff: + return false + default: + return false + } +} + +// overBudget reports whether the estimated context exceeds the compression +// threshold of the window. +func overBudget(instruction string, msgs []chatMessage, window int) bool { + limit := contextLimit(window) + if limit == 0 { + return false + } + return float64(estimateContextTokens(instruction, msgs)) > float64(limit)*compressionThreshold +} + +// summaryBudget is the number of tokens one summary may occupy. +func summaryBudget(window int) int { + limit := contextLimit(window) + if limit == 0 { + return 0 + } + return int(float64(limit) * summaryBudgetFraction) +} + +// toolRound is one assistant turn that called tools, together with the +// results that answer it. +type toolRound struct { + // start is the index of the assistant message. + start int + // end is the index after the last result of the round. + end int +} + +// toolRounds finds the complete tool rounds of a transcript. A round is +// complete when every call it made was answered: a half-answered round is +// reconciled after the run, not here. +func toolRounds(msgs []chatMessage) []toolRound { + var rounds []toolRound + for i := 0; i < len(msgs); i++ { + if msgs[i].Role != roleAssistant || len(msgs[i].ToolCalls) == 0 { + continue + } + j := i + 1 + for j < len(msgs) && msgs[j].Role == roleTool { + j++ + } + if j-i-1 != len(msgs[i].ToolCalls) { + continue + } + rounds = append(rounds, toolRound{start: i, end: j}) + i = j - 1 + } + return rounds +} + +// splitForCompression splits a transcript into the older prefix to compact +// and the tail to keep verbatim. +// +// The tail always holds the current user message, the most recent complete +// tool rounds and the last few messages. It never starts in the middle of a +// tool round: a tool result whose call was cut away is rejected by the +// provider. +func splitForCompression(msgs []chatMessage, keepMessages, keepRounds int) (prefix, keep []chatMessage) { + if len(msgs) == 0 { + return nil, nil + } + from := len(msgs) - keepMessages + if from < 0 { + from = 0 + } + // A tail that starts with a tool result orphans it: move the cut back + // to the assistant message that called it. + for from > 0 && msgs[from].Role == roleTool { + from-- + } + // The most recent complete tool rounds stay verbatim. + rounds := toolRounds(msgs) + for i := len(rounds) - 1; i >= 0 && i >= len(rounds)-keepRounds; i-- { + if rounds[i].start < from { + from = rounds[i].start + } + } + return msgs[:from], msgs[from:] +} + +// renderTranscript renders messages as plain text, so the summariser sees +// what happened and not just the conclusions. +func renderTranscript(msgs []chatMessage) string { + var b strings.Builder + for _, m := range msgs { + switch m.Role { + case roleSystem: + b.WriteString("system: " + m.Content + "\n") + case roleUser: + b.WriteString("user: " + m.Content + "\n") + case roleAssistant: + if m.Content != "" { + b.WriteString("assistant: " + m.Content + "\n") + } + for _, call := range m.ToolCalls { + b.WriteString("assistant called " + call.Function.Name + " " + call.Function.Arguments + "\n") + } + case roleTool: + b.WriteString("tool result: " + m.Content + "\n") + } + } + return b.String() +} + +// summarize asks the provider for a summary of the compacted prefix. It +// returns an empty string when the provider cannot answer: the caller then +// falls back to a deterministic truncation and the prompt still runs. +// +// The summary is produced by the same provider and model as the run, with no +// tools attached, and with the cheapest reasoning effort the configuration +// allows. +func (s *Service) summarize(ctx context.Context, cfg Config, prefix []chatMessage, window int) string { + if len(prefix) == 0 { + return "" + } + summaryCfg := cfg + summaryCfg.ThinkingStrength = summaryThinking(cfg.ThinkingStrength) + chatModel, err := s.model(ctx, summaryCfg) + if err != nil { + return "" + } + limit := summaryBudget(window) + ctx, cancel := context.WithTimeout(ctx, s.summaryDeadline()) + defer cancel() + + out, err := chatModel.Generate(ctx, []*schema.Message{ + schema.SystemMessage(summaryInstruction), + schema.UserMessage(renderTranscript(prefix)), + }) + if err != nil && reasoningRejected(err) && summaryCfg.ThinkingStrength != ThinkingAuto { + // The provider does not know the reasoning parameter: ask again + // without it. + summaryCfg.ThinkingStrength = ThinkingAuto + if retry, retryErr := s.model(ctx, summaryCfg); retryErr == nil { + out, err = retry.Generate(ctx, []*schema.Message{ + schema.SystemMessage(summaryInstruction), + schema.UserMessage(renderTranscript(prefix)), + }) + } + } + if err != nil || out == nil { + return "" + } + text := strings.TrimSpace(out.Content) + if text == "" { + return "" + } + if maxChars := limit * charsPerToken; maxChars > 0 && len(text) > maxChars { + text = strings.TrimSpace(text[:maxChars]) + "…" + } + return summaryPrefix + "\n" + text +} + +// summaryThinking is the reasoning effort used for a summary: the cheapest +// explicit level when the provider was asked for one, and the provider's +// own choice otherwise. +func summaryThinking(strength string) string { + switch strings.ToLower(strings.TrimSpace(strength)) { + case ThinkingLow, ThinkingMedium, ThinkingHigh: + return ThinkingLow + default: + return ThinkingAuto + } +} + +// summaryDeadline bounds one summarisation request. It never exceeds the +// request timeout of the run. +func (s *Service) summaryDeadline() time.Duration { + if s.requestTimeout > 0 && s.requestTimeout < summaryTimeout { + return s.requestTimeout + } + return summaryTimeout +} + +// fallbackSummary builds a summary without the provider: the older turns are +// listed, newest first, until the budget is used up. It is what keeps a +// prompt alive when the provider cannot be reached. +func fallbackSummary(prefix []chatMessage, window int) string { + maxChars := summaryBudget(window) * charsPerToken + var b strings.Builder + for i := len(prefix) - 1; i >= 0; i-- { + m := prefix[i] + var line string + switch m.Role { + case roleUser: + line = "user asked: " + m.Content + case roleAssistant: + line = "assistant said: " + m.Content + for _, call := range m.ToolCalls { + line += "\nassistant called " + call.Function.Name + " " + call.Function.Arguments + } + case roleTool: + line = "tool result: " + m.Content + case roleSystem: + line = "earlier summary: " + m.Content + } + line = strings.TrimSpace(line) + if line == "" { + continue + } + if maxChars > 0 && b.Len()+len(line)+1 > maxChars { + break + } + b.WriteString("- " + line + "\n") + } + body := strings.TrimSpace(b.String()) + if body == "" { + body = "- the earlier turns of this conversation were compacted" + } + return summaryPrefix + "\n" + body +} + +// prepareContext compacts the transcript when the configured context window +// is nearly exhausted. The compacted transcript replaces the old one, so the +// next prompt sees the summary instead of the raw turns. +// +// Nothing happens without a context window or with the compression turned +// off: that is the legacy behaviour, where only the plain history cap +// applies. +func (s *Service) prepareContext(ctx context.Context, sessionID, title string, cfg Config, c *conversation) { + if !compressionEnabled(cfg) { + return + } + instruction := systemPrompt(title) + history := s.snapshot(c) + if !overBudget(instruction, history, cfg.ContextWindow) { + return + } + compressed := s.compress(ctx, cfg, instruction, history) + if len(compressed) == 0 { + return + } + s.mu.Lock() + // A conversation that was cleared or disposed meanwhile keeps its own + // (empty) transcript. + if s.convs[sessionID] == c { + c.messages = compressed + } + s.mu.Unlock() +} + +// compress compacts a transcript until it fits the budget: the older prefix +// becomes one summary, and when that is not enough the remainder is +// compacted again, then the older tool results are dropped, and finally the +// plain history cap applies. +func (s *Service) compress(ctx context.Context, cfg Config, instruction string, history []chatMessage) []chatMessage { + window := cfg.ContextWindow + + prefix, keep := splitForCompression(history, keepRecentMessages, keepToolRounds) + if len(prefix) == 0 { + // Nothing older to compact: only the cap can help. + return trimHistory(history) + } + compressed := append([]chatMessage{{Role: roleSystem, Content: s.summaryOf(ctx, cfg, prefix, window)}}, keep...) + if !overBudget(instruction, compressed, window) { + return compressed + } + + // Still over budget: compact the remainder again, harder. + prefix, keep = splitForCompression(compressed, keepRecentMessages/2, 1) + if len(prefix) > 0 { + compressed = append([]chatMessage{{Role: roleSystem, Content: s.summaryOf(ctx, cfg, prefix, window)}}, keep...) + if !overBudget(instruction, compressed, window) { + return compressed + } + } + + // Still over budget: the older tool results are the bulkiest part of a + // tool-heavy transcript. + compressed = dropOldToolResults(compressed, 1) + if !overBudget(instruction, compressed, window) { + return compressed + } + return trimHistory(compressed) +} + +// summaryOf summarises a prefix, falling back to a deterministic truncation +// when the provider cannot answer. +func (s *Service) summaryOf(ctx context.Context, cfg Config, prefix []chatMessage, window int) string { + if summary := s.summarize(ctx, cfg, prefix, window); summary != "" { + return summary + } + return fallbackSummary(prefix, window) +} + +// dropOldToolResults removes the results of the older tool rounds. The +// assistant message that called them goes with them: a tool call without its +// result is rejected by the provider, so the call is replaced by a short +// note instead. +func dropOldToolResults(msgs []chatMessage, keepRounds int) []chatMessage { + rounds := toolRounds(msgs) + if len(rounds) <= keepRounds { + return msgs + } + out := msgs + // The rounds are dropped from the newest to the oldest, so the indices + // of the rounds that are still to be dropped stay valid. + for i := len(rounds) - keepRounds - 1; i >= 0; i-- { + out = dropToolRound(out, rounds[i]) + } + return out +} + +// dropToolRound replaces one tool round with a short assistant note. +func dropToolRound(msgs []chatMessage, r toolRound) []chatMessage { + if r.start < 0 || r.end > len(msgs) || r.start >= r.end { + return msgs + } + names := make([]string, 0, len(msgs[r.start].ToolCalls)) + for _, call := range msgs[r.start].ToolCalls { + names = append(names, call.Function.Name) + } + note := chatMessage{ + Role: roleAssistant, + Content: fmt.Sprintf("(earlier tool call: %s; its output was dropped to fit the context window)", + strings.Join(names, ", ")), + } + out := make([]chatMessage, 0, len(msgs)-(r.end-r.start)+1) + out = append(out, msgs[:r.start]...) + out = append(out, note) + out = append(out, msgs[r.end:]...) + return out +} diff --git a/internal/agent/context_test.go b/internal/agent/context_test.go new file mode 100644 index 0000000..1c03359 --- /dev/null +++ b/internal/agent/context_test.go @@ -0,0 +1,506 @@ +package agent + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/cloudwego/eino/schema" +) + +// The context layer keeps one conversation inside the configured window: +// it estimates the size of a request, compacts the older turns into a +// summary and never lets a failing compaction cost the user their prompt. + +const ( + // testWindow is the context budget the compaction tests run with. It is + // the smallest budget the settings store allows. + testWindow = 4096 + // bigChunk is the size of one seeded transcript message. + bigChunk = 1500 +) + +// --- the estimator --- + +// The estimate is characters/4 plus a fixed reserve for the tool schemas. +func TestEstimateTokensCountsCharactersAndToolReserve(t *testing.T) { + cases := []struct { + chars int + want int + }{ + {0, 0}, + {1, 1}, + {4, 1}, + {5, 2}, + {400, 100}, + } + for _, tc := range cases { + if got := estimateTokens(tc.chars); got != tc.want { + t.Fatalf("estimateTokens(%d) = %d, want %d", tc.chars, got, tc.want) + } + } + + msgs := []chatMessage{{Role: roleUser, Content: strings.Repeat("a", 400)}} + instruction := "system prompt" + got := estimateContextTokens(instruction, msgs) + want := estimateTokens(len(instruction)+400) + toolSchemaReserveTokens + if got != want { + t.Fatalf("estimateContextTokens = %d, want %d", got, want) + } + if want != 1104 { + t.Fatalf("the estimate changed: %d tokens for 413 characters plus the reserve", want) + } +} + +// The tool schemas are counted for every request, and the tool calls of the +// transcript count as well. +func TestEstimateCountsTheToolCallsOfTheTranscript(t *testing.T) { + msgs := []chatMessage{{ + Role: roleAssistant, + ToolCalls: []toolCall{{ + ID: "c1", + Type: "function", + Function: functionCall{Name: toolBash, Arguments: `{"command":"ls"}`}, + }}, + }} + plain := estimateContextTokens("", msgs) + if plain != toolSchemaReserveTokens+estimateTokens(len(toolBash)+len(`{"command":"ls"}`)) { + t.Fatalf("estimateContextTokens = %d, want the reserve plus the call", plain) + } + msgs[0].ToolCalls[0].Function.Arguments = strings.Repeat("x", 400) + if got := estimateContextTokens("", msgs); got <= plain { + t.Fatalf("estimateContextTokens = %d, want more than %d", got, plain) + } +} + +// The compaction starts when the estimate passes 80% of the window. +func TestOverBudgetUsesTheCompressionThreshold(t *testing.T) { + instruction := systemPrompt("host") + base := estimateContextTokens(instruction, nil) + threshold := float64(testWindow) * compressionThreshold + room := int(threshold) - base + if room <= 0 { + t.Fatalf("the test window is too small: %d tokens of room", room) + } + under := []chatMessage{{Role: roleUser, Content: strings.Repeat("a", room*charsPerToken)}} + if overBudget(instruction, under, testWindow) { + t.Fatal("a transcript at the threshold must not trigger the compaction") + } + over := []chatMessage{{Role: roleUser, Content: strings.Repeat("a", room*charsPerToken+1)}} + if !overBudget(instruction, over, testWindow) { + t.Fatal("a transcript above the threshold must trigger the compaction") + } + // Without a window there is no budget at all. + if overBudget(instruction, over, 0) { + t.Fatal("a window of 0 must never trigger the compaction") + } +} + +// --- the split --- + +// The compaction never splits a tool round: the kept tail starts at the +// assistant message that called the tools, and every result in it has its +// call next to it. +func TestSplitForCompressionKeepsCompleteToolRounds(t *testing.T) { + msgs := []chatMessage{ + {Role: roleUser, Content: "first"}, + {Role: roleAssistant, ToolCalls: []toolCall{{ + ID: "c1", Type: "function", + Function: functionCall{Name: toolBash, Arguments: `{"command":"ls"}`}, + }}}, + {Role: roleTool, ToolCallID: "c1", Content: "out"}, + {Role: roleUser, Content: "second"}, + {Role: roleAssistant, ToolCalls: []toolCall{{ + ID: "c2", Type: "function", + Function: functionCall{Name: toolBash, Arguments: `{"command":"df"}`}, + }}}, + {Role: roleTool, ToolCallID: "c2", Content: "out"}, + {Role: roleUser, Content: "third"}, + } + prefix, keep := splitForCompression(msgs, 2, 2) + + if len(prefix) == 0 { + t.Fatal("the older turn must be compacted") + } + if keep[0].Role != roleAssistant { + t.Fatalf("the kept tail starts with a %q, want the assistant of a tool round", keep[0].Role) + } + calls := map[string]bool{} + for _, m := range keep { + for _, call := range m.ToolCalls { + calls[call.ID] = true + } + } + for _, m := range keep { + if m.Role != roleTool { + continue + } + if !calls[m.ToolCallID] { + t.Fatalf("the kept tail orphaned the result of %q", m.ToolCallID) + } + } + // The most recent complete rounds are kept verbatim. + if !hasCall(keep, "c1") || !hasCall(keep, "c2") { + t.Fatalf("the kept tail lost a tool round: %+v", keep) + } +} + +// hasCall reports whether the messages carry the call with that id. +func hasCall(msgs []chatMessage, id string) bool { + for _, m := range msgs { + for _, call := range m.ToolCalls { + if call.ID == id { + return true + } + } + } + return false +} + +// --- the service --- + +// seededHistory is a transcript that already fills the context window: it +// holds two complete tool rounds and enough text to trip the threshold. +func seededHistory() []chatMessage { + chunk := strings.Repeat("x", bigChunk) + call := func(id string) []toolCall { + return []toolCall{{ + ID: id, Type: "function", + Function: functionCall{Name: toolBash, Arguments: `{"command":"ls"}`}, + }} + } + return []chatMessage{ + {Role: roleUser, Content: chunk}, + {Role: roleAssistant, Content: chunk}, + {Role: roleAssistant, ToolCalls: call("c1")}, + {Role: roleTool, ToolCallID: "c1", Content: chunk}, + {Role: roleUser, Content: chunk}, + {Role: roleAssistant, ToolCalls: call("c2")}, + {Role: roleTool, ToolCallID: "c2", Content: chunk}, + {Role: roleUser, Content: chunk}, + {Role: roleAssistant, Content: chunk}, + } +} + +// A transcript that outgrows the window is compacted, and the summary +// replaces the older turns in the transcript the next prompt replays. +func TestCompressionRewritesTheTranscriptWithTheSummary(t *testing.T) { + h := newHarness(t, []fakeTurn{{chunks: []string{"ok"}}}, nil) + h.model.setSummaries("- the user asked about the disk") + h.seedHistory("s1", seededHistory()) + + cfg := h.cfg() + cfg.ContextWindow = testWindow + cfg.ContextCompression = CompressionAuto + if err := h.svc.Prompt("s1", "host", "and now?", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + h.waitDone(t, 1) + + history := h.history("s1") + if len(history) == 0 { + t.Fatal("the transcript is empty") + } + if history[0].Role != roleSystem { + t.Fatalf("the transcript starts with a %q, want the summary", history[0].Role) + } + if !strings.HasPrefix(history[0].Content, summaryPrefix) { + t.Fatalf("the summary is not marked: %q", history[0].Content) + } + if !strings.Contains(history[0].Content, "the user asked about the disk") { + t.Fatalf("the summary is not the one the provider wrote: %q", history[0].Content) + } + // The compacted turn is gone from the transcript: the kept tail starts + // at the assistant turn that called the first tool. + if len(history) < 2 { + t.Fatalf("the transcript is too short: %+v", history) + } + if history[1].Role != roleAssistant || !hasCall(history[1:], "c1") { + t.Fatalf("the compacted turn survived: %+v", history[1]) + } + // The provider was asked for the summary of the older turns only. + calls := h.model.summaryCalls() + if len(calls) != 1 { + t.Fatalf("summary calls = %d, want one", len(calls)) + } + // The model read the summary: it is part of the system prompt. + last := h.model.request(h.model.callCount() - 1) + if len(last) == 0 || last[0].Role != schema.System { + t.Fatalf("the request has no system prompt: %+v", last) + } + if !strings.Contains(last[0].Content, "the user asked about the disk") { + t.Fatalf("the model did not see the summary: %q", last[0].Content) + } + // The compacted transcript fits the window again. + if overBudget(systemPrompt("host"), history, testWindow) { + t.Fatal("the compacted transcript still exceeds the window") + } +} + +// A provider that cannot summarise costs the user nothing: the run still +// happens, with a deterministic summary in place of the model's one. +func TestSummaryFailureFallsBackToTruncation(t *testing.T) { + h := newHarness(t, []fakeTurn{{chunks: []string{"ok"}}}, nil) + h.model.failSummary(errors.New("provider is down")) + h.seedHistory("s1", seededHistory()) + + cfg := h.cfg() + cfg.ContextWindow = testWindow + cfg.ContextCompression = CompressionAuto + if err := h.svc.Prompt("s1", "host", "and now?", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + h.waitDone(t, 1) + + if errs := h.sink.errors(); len(errs) != 0 { + t.Fatalf("a failed summary must not fail the prompt: %+v", errs) + } + if got := h.sink.text(); got != "ok" { + t.Fatalf("streamed text = %q, want the answer of the run", got) + } + history := h.history("s1") + if len(history) == 0 || history[0].Role != roleSystem { + t.Fatalf("the transcript does not start with a summary: %+v", history) + } + if !strings.HasPrefix(history[0].Content, summaryPrefix) { + t.Fatalf("the fallback summary is not marked: %q", history[0].Content) + } + if !strings.Contains(history[0].Content, "user asked") { + t.Fatalf("the fallback summary keeps nothing: %q", history[0].Content) + } +} + +// A prompt that cannot fit the window is rejected before the run starts. +func TestOversizedPromptIsRejectedBeforeTheRun(t *testing.T) { + h := newHarness(t, []fakeTurn{{chunks: []string{"ok"}}}, nil) + + cfg := h.cfg() + cfg.ContextWindow = testWindow + cfg.ContextCompression = CompressionAuto + err := h.svc.Prompt("s1", "host", strings.Repeat("x", 20000), cfg) + if err == nil { + t.Fatal("a prompt that outgrows the window must be rejected") + } + if !strings.Contains(err.Error(), "context window") { + t.Fatalf("error = %v, want the context window message", err) + } + if h.sink.count(EventDone) != 0 { + t.Fatal("a rejected prompt must not start a run") + } + if got := h.history("s1"); len(got) != 0 { + t.Fatalf("a rejected prompt must not reach the transcript: %+v", got) + } +} + +// Without a context window nothing is compacted: the provider's own limit +// decides, as before. +func TestContextWindowZeroKeepsTheLegacyBehaviour(t *testing.T) { + h := newHarness(t, []fakeTurn{{chunks: []string{"ok"}}}, nil) + seeded := seededHistory() + h.seedHistory("s1", seeded) + + cfg := h.cfg() + if err := h.svc.Prompt("s1", "host", "and now?", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + h.waitDone(t, 1) + + history := h.history("s1") + if len(history) != len(seeded)+2 { + t.Fatalf("the transcript holds %d messages, want %d", len(history), len(seeded)+2) + } + for _, m := range history { + if m.Role == roleSystem { + t.Fatal("a transcript without a window must not be compacted") + } + } + if calls := h.model.summaryCalls(); len(calls) != 0 { + t.Fatalf("summary calls = %d, want none", len(calls)) + } +} + +// The compaction is off unless it was asked for: the plain history cap stays +// the only limit. +func TestCompressionOffKeepsTheLegacyBehaviour(t *testing.T) { + h := newHarness(t, []fakeTurn{{chunks: []string{"ok"}}}, nil) + seeded := seededHistory() + h.seedHistory("s1", seeded) + + cfg := h.cfg() + cfg.ContextWindow = testWindow + cfg.ContextCompression = CompressionOff + if err := h.svc.Prompt("s1", "host", "and now?", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + h.waitDone(t, 1) + + history := h.history("s1") + if len(history) != len(seeded)+2 { + t.Fatalf("the transcript holds %d messages, want %d", len(history), len(seeded)+2) + } + for _, m := range history { + if m.Role == roleSystem { + t.Fatal("a transcript with the compaction off must not be compacted") + } + } + if calls := h.model.summaryCalls(); len(calls) != 0 { + t.Fatalf("summary calls = %d, want none", len(calls)) + } +} + +// The summary never eats more than its share of the window. +func TestSummaryIsCappedToItsShareOfTheWindow(t *testing.T) { + h := newHarness(t, []fakeTurn{{chunks: []string{"ok"}}}, nil) + h.model.setSummaries(strings.Repeat("s", 100000)) + h.seedHistory("s1", seededHistory()) + + cfg := h.cfg() + cfg.ContextWindow = testWindow + cfg.ContextCompression = CompressionAuto + if err := h.svc.Prompt("s1", "host", "and now?", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + h.waitDone(t, 1) + + history := h.history("s1") + if len(history) == 0 || history[0].Role != roleSystem { + t.Fatalf("the transcript does not start with a summary: %+v", history) + } + limit := summaryBudget(testWindow) * charsPerToken + if len(history[0].Content) > limit+len(summaryPrefix)+2 { + t.Fatalf("the summary holds %d characters, want at most %d", len(history[0].Content), limit) + } +} + +// A transcript that stays over the budget after the compaction still runs: +// the older tool results are dropped and the plain history cap applies. +func TestOverBudgetTranscriptDropsTheOlderToolResults(t *testing.T) { + h := newHarness(t, []fakeTurn{{chunks: []string{"ok"}}}, nil) + h.model.setSummaries("- first summary", "- second summary") + + chunk := strings.Repeat("x", 6000) + call := func(id string) []toolCall { + return []toolCall{{ + ID: id, Type: "function", + Function: functionCall{Name: toolBash, Arguments: `{"command":"ls"}`}, + }} + } + var seeded []chatMessage + for i := 1; i <= 5; i++ { + id := fmt.Sprintf("c%d", i) + seeded = append(seeded, + chatMessage{Role: roleAssistant, ToolCalls: call(id)}, + chatMessage{Role: roleTool, ToolCallID: id, Content: chunk}, + ) + } + seeded = append([]chatMessage{{Role: roleUser, Content: chunk}}, seeded...) + h.seedHistory("s1", seeded) + + cfg := h.cfg() + cfg.ContextWindow = testWindow + cfg.ContextCompression = CompressionAuto + if err := h.svc.Prompt("s1", "host", "and now?", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + h.waitDone(t, 1) + + if errs := h.sink.errors(); len(errs) != 0 { + t.Fatalf("the run must survive an oversized transcript: %+v", errs) + } + history := h.history("s1") + if len(history) == 0 { + t.Fatal("the transcript is empty") + } + if history[0].Role != roleSystem { + t.Fatalf("the transcript starts with a %q, want the summary", history[0].Role) + } + // The older tool results are gone: at most one round survived. + kept := 0 + for _, m := range history { + if m.Role == roleTool { + kept++ + } + } + if kept > 2 { + t.Fatalf("the transcript kept %d tool results, want at most one round", kept) + } + if overBudget(systemPrompt("host"), history, testWindow) { + t.Fatal("the compacted transcript still exceeds the window") + } +} + +// The compaction needs both a budget and the compression turned on. +func TestCompressionNeedsAWindowAndTheMode(t *testing.T) { + cases := []struct { + window int + compression string + want bool + }{ + {0, CompressionAuto, false}, + {0, CompressionOff, false}, + {testWindow, CompressionOff, false}, + {testWindow, "", false}, + {testWindow, "sometimes", false}, + {testWindow, CompressionAuto, true}, + } + for _, tc := range cases { + cfg := Config{ContextWindow: tc.window, ContextCompression: tc.compression} + if got := compressionEnabled(cfg); got != tc.want { + t.Fatalf("compressionEnabled(window=%d, mode=%q) = %t, want %t", + tc.window, tc.compression, got, tc.want) + } + } +} + +// The summary is written with the cheapest reasoning effort the provider +// was asked for. +func TestSummaryUsesTheCheapestReasoningEffort(t *testing.T) { + if got := summaryThinking(ThinkingHigh); got != ThinkingLow { + t.Fatalf("summaryThinking(high) = %q, want %q", got, ThinkingLow) + } + if got := summaryThinking(ThinkingMedium); got != ThinkingLow { + t.Fatalf("summaryThinking(medium) = %q, want %q", got, ThinkingLow) + } + if got := summaryThinking(ThinkingAuto); got != ThinkingAuto { + t.Fatalf("summaryThinking(auto) = %q, want %q", got, ThinkingAuto) + } + if got := summaryThinking("nonsense"); got != ThinkingAuto { + t.Fatalf("summaryThinking(nonsense) = %q, want %q", got, ThinkingAuto) + } +} + +// The summary is asked of the same provider and model as the run, with the +// cheapest reasoning effort. +func TestSummaryRunsOnTheSameProviderWithALowEffort(t *testing.T) { + h := newHarness(t, []fakeTurn{{chunks: []string{"ok"}}}, nil) + h.model.setSummaries("- the user asked about the disk") + h.seedHistory("s1", seededHistory()) + + cfg := h.cfg() + cfg.ContextWindow = testWindow + cfg.ContextCompression = CompressionAuto + cfg.ThinkingStrength = ThinkingHigh + if err := h.svc.Prompt("s1", "host", "and now?", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + h.waitDone(t, 1) + + seen := h.seenConfigs() + if len(seen) != 2 { + t.Fatalf("model factory calls = %d, want the run and the summary", len(seen)) + } + efforts := map[string]int{} + for _, cfg := range seen { + efforts[cfg.ThinkingStrength]++ + } + if efforts[ThinkingLow] != 1 { + t.Fatalf("the summary did not ask for the lowest effort: %+v", seen) + } + if efforts[ThinkingHigh] != 1 { + t.Fatalf("the run did not ask for the configured effort: %+v", seen) + } + if seen[0].Model != seen[1].Model || seen[0].BaseURL != seen[1].BaseURL { + t.Fatalf("the summary left the provider: %+v", seen) + } +} diff --git a/internal/agent/eino_runner.go b/internal/agent/eino_runner.go index 4dab194..589a0d5 100644 --- a/internal/agent/eino_runner.go +++ b/internal/agent/eino_runner.go @@ -27,17 +27,43 @@ const defaultMaxIterations = 20 // The NodeShell transcript stays the source of truth: it is replayed to the // agent as the model input, and whatever the agent produced is handed back so // the caller can append it. +// +// A provider that refuses the reasoning parameter is given a second chance +// without it: the run continues instead of failing over a parameter the +// model does not know. func (s *Service) runEino(ctx context.Context, sessionID, title string, cfg Config, c *conversation) ([]chatMessage, error) { + produced, err := s.runEinoAttempt(ctx, sessionID, title, cfg, c) + if err == nil { + return produced, nil + } + // Only a run that produced nothing is retried: nothing was executed and + // the transcript is untouched, so the second attempt starts from the + // same place. + if len(produced) > 0 || !reasoningRejected(err) { + return produced, mapRunError(err, cfg, s.maxIterations()) + } + fallback := cfg + fallback.ThinkingStrength = ThinkingAuto + produced, retryErr := s.runEinoAttempt(ctx, sessionID, title, fallback, c) + if retryErr != nil { + return produced, mapRunError(retryErr, fallback, s.maxIterations()) + } + return produced, nil +} + +// runEinoAttempt runs the Eino agent once and returns the raw error, so the +// caller can tell a provider rejection apart from any other failure. +func (s *Service) runEinoAttempt(ctx context.Context, sessionID, title string, cfg Config, c *conversation) ([]chatMessage, error) { chatModel, err := s.model(ctx, cfg) if err != nil { return nil, err } - history := toSchemaMessages(s.snapshot(c)) + instruction, history := modelInput(title, s.snapshot(c)) einoAgent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{ Name: einoAgentName, Description: "The NodeShell in-app assistant for one SSH session.", - Instruction: systemPrompt(title), + Instruction: instruction, Model: chatModel, ToolsConfig: adk.ToolsConfig{ ToolsNodeConfig: compose.ToolsNodeConfig{ @@ -91,7 +117,7 @@ func (s *Service) runEino(ctx context.Context, sessionID, title string, cfg Conf break } if event.Err != nil { - return produced, mapRunError(event.Err, cfg, s.maxIterations()) + return produced, event.Err } if event.Output == nil || event.Output.MessageOutput == nil { continue @@ -101,12 +127,26 @@ func (s *Service) runEino(ctx context.Context, sessionID, title string, cfg Conf produced = append(produced, fromSchemaMessage(msg)) } if msgErr != nil { - return produced, mapRunError(msgErr, cfg, s.maxIterations()) + return produced, msgErr } } return produced, nil } +// modelInput splits the transcript into the instruction and the messages the +// model sees. A compacted transcript starts with its summary; that summary +// belongs to the system prompt, so the model reads one system message +// instead of two. +func modelInput(title string, history []chatMessage) (string, []*schema.Message) { + instruction := systemPrompt(title) + msgs := history + if len(msgs) > 0 && msgs[0].Role == roleSystem { + instruction = instruction + "\n\n" + msgs[0].Content + msgs = msgs[1:] + } + return instruction, toSchemaMessages(msgs) +} + // buildTools binds the remote tools to the session the prompt named. func (s *Service) buildTools(sessionID, title string, c *conversation) []tool.BaseTool { defs := toolDefs() diff --git a/internal/agent/errors.go b/internal/agent/errors.go index 3f19da4..34e487f 100644 --- a/internal/agent/errors.go +++ b/internal/agent/errors.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net" + "net/http" "net/url" "strings" @@ -13,6 +14,29 @@ import ( "nodeshell/internal/apperror" ) +// reasoningRejected reports whether a provider refused the request because of +// the reasoning parameter. Such a provider is given a second chance without +// the parameter instead of failing the run. +// +// The check is narrow on purpose: only a rejected request that names the +// reasoning parameter counts, so an unrelated provider error is never retried +// and never hidden. +func reasoningRejected(err error) bool { + if err == nil { + return false + } + var apiErr *openai.APIError + if !errors.As(err, &apiErr) { + return false + } + switch apiErr.HTTPStatusCode { + case http.StatusBadRequest, http.StatusUnprocessableEntity, http.StatusNotFound: + default: + return false + } + return strings.Contains(strings.ToLower(apiErr.Message), "reasoning") +} + // mapRunError turns a failure of the Eino run into a coded error the UI can // show. The provider message is kept because it is what makes a wrong model // or a rejected key diagnosable, but it is bounded and the configured API key @@ -21,6 +45,11 @@ func mapRunError(err error, cfg Config, maxIterations int) error { if err == nil { return nil } + // An error the agent already coded keeps its own message. + var coded *Error + if errors.As(err, &coded) { + return coded + } // A cancelled context is the user pressing stop, not a failure. if errors.Is(err, context.Canceled) { return context.Canceled diff --git a/internal/agent/model.go b/internal/agent/model.go index b4a12af..a85efb0 100644 --- a/internal/agent/model.go +++ b/internal/agent/model.go @@ -3,6 +3,7 @@ package agent import ( "context" "net/http" + "strings" "github.com/cloudwego/eino-ext/components/model/openai" "github.com/cloudwego/eino/components/model" @@ -18,9 +19,26 @@ type ModelFactory func(ctx context.Context, cfg Config) (model.ToolCallingChatMo // part of the model name, the base URL or any message. func newOpenAIModel(ctx context.Context, cfg Config, client *http.Client) (model.ToolCallingChatModel, error) { return openai.NewChatModel(ctx, &openai.ChatModelConfig{ - BaseURL: cfg.BaseURL, - Model: cfg.Model, - APIKey: cfg.APIKey, - HTTPClient: client, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + APIKey: cfg.APIKey, + HTTPClient: client, + ReasoningEffort: reasoningEffort(cfg.ThinkingStrength), }) } + +// reasoningEffort maps the configured thinking strength onto the provider's +// reasoning parameter. "auto" - and anything unknown - leaves the parameter +// unset, so the provider decides how much it thinks. +func reasoningEffort(strength string) openai.ReasoningEffortLevel { + switch strings.ToLower(strings.TrimSpace(strength)) { + case ThinkingLow: + return openai.ReasoningEffortLevelLow + case ThinkingMedium: + return openai.ReasoningEffortLevelMedium + case ThinkingHigh: + return openai.ReasoningEffortLevelHigh + default: + return "" + } +} diff --git a/internal/agent/thinking_test.go b/internal/agent/thinking_test.go new file mode 100644 index 0000000..219684e --- /dev/null +++ b/internal/agent/thinking_test.go @@ -0,0 +1,240 @@ +package agent + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/cloudwego/eino-ext/components/model/openai" +) + +// The thinking strength is the user's knob on the provider's reasoning +// parameter: it is sent when it is asked for, left out when the provider +// should decide, and a provider that refuses it does not cost the user the +// run. + +func TestReasoningEffortMapsTheThinkingStrength(t *testing.T) { + cases := []struct { + strength string + want openai.ReasoningEffortLevel + }{ + {ThinkingAuto, ""}, + {"", ""}, + {"nonsense", ""}, + {ThinkingLow, openai.ReasoningEffortLevelLow}, + {ThinkingMedium, openai.ReasoningEffortLevelMedium}, + {ThinkingHigh, openai.ReasoningEffortLevelHigh}, + {"HIGH", openai.ReasoningEffortLevelHigh}, + {" high ", openai.ReasoningEffortLevelHigh}, + } + for _, tc := range cases { + if got := reasoningEffort(tc.strength); got != tc.want { + t.Fatalf("reasoningEffort(%q) = %q, want %q", tc.strength, got, tc.want) + } + } +} + +// The reasoning parameter reaches the provider when the user asked for a +// strength, and stays away when the provider should decide. +func TestThinkingStrengthReachesTheProvider(t *testing.T) { + cases := []struct { + strength string + want string + }{ + {ThinkingAuto, ""}, + {ThinkingLow, "low"}, + {ThinkingMedium, "medium"}, + {ThinkingHigh, "high"}, + } + for _, tc := range cases { + t.Run(tc.strength, func(t *testing.T) { + prov := &providerServer{frames: []string{ + "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"hi\"}}]}\n\n", + }} + srv := httptest.NewServer(prov) + defer srv.Close() + + sink := &recSink{} + svc := New(Deps{Tools: &fakeTools{}, Sink: sink}) + defer svc.DisposeAll() + + cfg := Config{ + BaseURL: srv.URL + "/v1", + Model: "test-model", + APIKey: "sk-test-key", + ThinkingStrength: tc.strength, + } + if err := svc.Prompt("s1", "host", "hello", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + waitFor(t, "the run to close", func() bool { return sink.count(EventDone) == 1 }) + + _, _, bodies := prov.recorded() + if len(bodies) != 1 { + t.Fatalf("requests = %d, want one", len(bodies)) + } + var payload map[string]any + if err := json.Unmarshal([]byte(bodies[0]), &payload); err != nil { + t.Fatalf("request body: %v (%s)", err, bodies[0]) + } + got, _ := payload["reasoning_effort"].(string) + if got != tc.want { + t.Fatalf("reasoning_effort = %q, want %q", got, tc.want) + } + if tc.want == "" { + if _, present := payload["reasoning_effort"]; present { + t.Fatalf("the reasoning parameter must be left out: %s", bodies[0]) + } + } + }) + } +} + +// A provider that refuses the reasoning parameter gets a second chance +// without it: the run continues instead of failing over a parameter the +// model does not know. +func TestProviderRejectingReasoningIsRetriedWithoutIt(t *testing.T) { + var mu sync.Mutex + var bodies []string + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + mu.Lock() + bodies = append(bodies, string(raw)) + calls++ + n := calls + mu.Unlock() + if n == 1 { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprint(w, `{"error":{"message":"Unrecognized request argument supplied: reasoning_effort"}}`) + return + } + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"content\":\"hi\"}}]}\n\n") + fmt.Fprint(w, "data: [DONE]\n\n") + })) + defer srv.Close() + + sink := &recSink{} + svc := New(Deps{Tools: &fakeTools{}, Sink: sink}) + defer svc.DisposeAll() + + cfg := Config{ + BaseURL: srv.URL + "/v1", + Model: "test-model", + APIKey: "sk-test-key", + ThinkingStrength: ThinkingHigh, + } + if err := svc.Prompt("s1", "host", "hello", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + waitFor(t, "the run to close", func() bool { return sink.count(EventDone) == 1 }) + + if errs := sink.errors(); len(errs) != 0 { + t.Fatalf("a refused reasoning parameter must not fail the run: %+v", errs) + } + if got := sink.text(); got != "hi" { + t.Fatalf("streamed text = %q, want the answer of the second attempt", got) + } + + mu.Lock() + defer mu.Unlock() + if len(bodies) != 2 { + t.Fatalf("requests = %d, want the refused one and the retry", len(bodies)) + } + var first, second map[string]any + if err := json.Unmarshal([]byte(bodies[0]), &first); err != nil { + t.Fatalf("first body: %v", err) + } + if err := json.Unmarshal([]byte(bodies[1]), &second); err != nil { + t.Fatalf("second body: %v", err) + } + if got, _ := first["reasoning_effort"].(string); got != "high" { + t.Fatalf("the first request carried reasoning_effort %q, want high", got) + } + if _, present := second["reasoning_effort"]; present { + t.Fatalf("the retry must not carry the reasoning parameter: %s", bodies[1]) + } + if strings.Contains(strings.Join(bodies, ""), "sk-test-key") { + t.Fatal("the API key must not appear in the request body") + } +} + +// A provider that refuses the reasoning parameter and then fails for another +// reason reports that reason, without the key in it. +func TestReasoningRejectionIsNotRetriedWithoutTheReason(t *testing.T) { + prov := &providerServer{ + status: http.StatusBadRequest, + body: `{"error":{"message":"Unrecognized request argument supplied: reasoning_effort"}}`, + } + srv := httptest.NewServer(prov) + defer srv.Close() + + sink := &recSink{} + svc := New(Deps{Tools: &fakeTools{}, Sink: sink}) + defer svc.DisposeAll() + + cfg := Config{ + BaseURL: srv.URL + "/v1", + Model: "test-model", + APIKey: "sk-test-key", + ThinkingStrength: ThinkingHigh, + } + if err := svc.Prompt("s1", "host", "hello", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + waitFor(t, "the run to close", func() bool { return sink.count(EventDone) == 1 }) + + errs := sink.errors() + if len(errs) != 1 { + t.Fatalf("error events = %+v, want one", errs) + } + if strings.Contains(errs[0].Error.Message, "sk-test-key") { + t.Fatalf("the key leaked into the error: %q", errs[0].Error.Message) + } + if !strings.Contains(errs[0].Error.Message, "reasoning") { + t.Fatalf("error message = %q, want the provider's reason", errs[0].Error.Message) + } + // The run was retried once without the parameter, and the provider + // refused that too. + _, _, bodies := prov.recorded() + if len(bodies) != 2 { + t.Fatalf("requests = %d, want the refused one and the retry", len(bodies)) + } +} + +// A provider failure that has nothing to do with the reasoning parameter is +// reported as it is: no second attempt, no hidden error. +func TestUnrelatedProviderFailureIsNotRetried(t *testing.T) { + prov := &providerServer{ + status: http.StatusBadRequest, + body: `{"error":{"message":"invalid model name"}}`, + } + srv := httptest.NewServer(prov) + defer srv.Close() + + sink := &recSink{} + svc := New(Deps{Tools: &fakeTools{}, Sink: sink}) + defer svc.DisposeAll() + + cfg := Config{ + BaseURL: srv.URL + "/v1", + Model: "test-model", + APIKey: "sk-test-key", + ThinkingStrength: ThinkingHigh, + } + if err := svc.Prompt("s1", "host", "hello", cfg); err != nil { + t.Fatalf("Prompt: %v", err) + } + waitFor(t, "the run to close", func() bool { return sink.count(EventDone) == 1 }) + + _, _, bodies := prov.recorded() + if len(bodies) != 1 { + t.Fatalf("requests = %d, want one: an unrelated failure is not retried", len(bodies)) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 3d3fb7c..9aec97d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1182,7 +1182,14 @@ func (a *App) agentConfigFor(providerID, model string) (agent.Config, error) { if key == "" { return agent.Config{}, agent.ErrNotConfigured } - return agent.Config{BaseURL: p.BaseURL, Model: strings.TrimSpace(model), APIKey: key}, nil + return agent.Config{ + BaseURL: p.BaseURL, + Model: strings.TrimSpace(model), + APIKey: key, + ContextWindow: current.AgentContextWindow, + ThinkingStrength: current.AgentThinkingStrength, + ContextCompression: current.AgentContextCompression, + }, nil } func (a *App) agentStatusFrom(current settings.AppSettings) (AgentConfigStatus, error) { diff --git a/internal/app/app_agent_test.go b/internal/app/app_agent_test.go index 1ab0897..b687812 100644 --- a/internal/app/app_agent_test.go +++ b/internal/app/app_agent_test.go @@ -200,6 +200,105 @@ func TestAgentConfigForRequiresKeyAndKnownModel(t *testing.T) { } } +// The runtime settings travel with every prompt: the context window, the +// thinking strength and the compression mode are part of the agent config +// the runner works with. +func TestAgentConfigForCarriesTheRuntimeSettings(t *testing.T) { + a, keys, _ := newAgentApp(t) + + got, err := a.AgentUpsertProvider(AgentProviderInput{ + Name: "P", BaseURL: "https://x.test/v1", Models: []string{"m"}, + }) + if err != nil { + t.Fatal(err) + } + id := got.Providers[0].ID + if err := keys.Set(credentials.ServiceName, agentKeyAccountFor(id), "sk-secret-value"); err != nil { + t.Fatal(err) + } + + // The defaults leave the budget to the provider. + cfg, err := a.agentConfigFor(id, "m") + if err != nil { + t.Fatalf("agentConfigFor: %v", err) + } + if cfg.ContextWindow != 0 { + t.Fatalf("context window = %d, want 0 (unlimited)", cfg.ContextWindow) + } + if cfg.ThinkingStrength != settings.AgentThinkingAuto { + t.Fatalf("thinking strength = %q, want %q", cfg.ThinkingStrength, settings.AgentThinkingAuto) + } + if cfg.ContextCompression != settings.AgentCompressionOff { + t.Fatalf("context compression = %q, want %q", cfg.ContextCompression, settings.AgentCompressionOff) + } + + window := 32768.0 + strength := settings.AgentThinkingHigh + compression := settings.AgentCompressionAuto + if _, err := a.SettingsSet(settings.Patch{ + AgentContextWindow: &window, + AgentThinkingStrength: &strength, + AgentContextCompression: &compression, + }); err != nil { + t.Fatalf("SettingsSet: %v", err) + } + + cfg, err = a.agentConfigFor(id, "m") + if err != nil { + t.Fatalf("agentConfigFor: %v", err) + } + if cfg.ContextWindow != 32768 { + t.Fatalf("context window = %d, want 32768", cfg.ContextWindow) + } + if cfg.ThinkingStrength != settings.AgentThinkingHigh { + t.Fatalf("thinking strength = %q, want %q", cfg.ThinkingStrength, settings.AgentThinkingHigh) + } + if cfg.ContextCompression != settings.AgentCompressionAuto { + t.Fatalf("context compression = %q, want %q", cfg.ContextCompression, settings.AgentCompressionAuto) + } + // The endpoint fields are untouched by the runtime settings. + if cfg.APIKey != "sk-secret-value" || cfg.Model != "m" || cfg.BaseURL != "https://x.test/v1" { + t.Fatalf("config = %+v, want the endpoint unchanged", cfg) + } +} + +// A value the settings store cannot make sense of never reaches the agent: +// the normalised default is used instead. +func TestAgentConfigForUsesTheNormalisedRuntimeSettings(t *testing.T) { + a, keys, _ := newAgentApp(t) + + got, err := a.AgentUpsertProvider(AgentProviderInput{ + Name: "P", BaseURL: "https://x.test/v1", Models: []string{"m"}, + }) + if err != nil { + t.Fatal(err) + } + id := got.Providers[0].ID + if err := keys.Set(credentials.ServiceName, agentKeyAccountFor(id), "sk-secret-value"); err != nil { + t.Fatal(err) + } + + strength := "ultra" + compression := "always" + if _, err := a.SettingsSet(settings.Patch{ + AgentThinkingStrength: &strength, + AgentContextCompression: &compression, + }); err != nil { + t.Fatalf("SettingsSet: %v", err) + } + + cfg, err := a.agentConfigFor(id, "m") + if err != nil { + t.Fatalf("agentConfigFor: %v", err) + } + if cfg.ThinkingStrength != settings.AgentThinkingAuto { + t.Fatalf("thinking strength = %q, want %q", cfg.ThinkingStrength, settings.AgentThinkingAuto) + } + if cfg.ContextCompression != settings.AgentCompressionOff { + t.Fatalf("context compression = %q, want %q", cfg.ContextCompression, settings.AgentCompressionOff) + } +} + func TestAgentLegacyMigrationCopiesKeyAndPersistsProvider(t *testing.T) { a, keys, dir := newAgentApp(t) if err := os.WriteFile(filepath.Join(dir, "settings.json"), []byte(