mirror of
https://github.com/strands-agents/harness-sdk.git
synced 2026-10-02 02:44:48 +08:00
feat(cli): add strands update and polish compact, skills, rename, and setup flows (#4612)
This commit is contained in:
@@ -59,6 +59,12 @@ Or run it on demand without installing, using `npx`:
|
||||
npx @strands-agents/cli
|
||||
```
|
||||
|
||||
Update a global installation with:
|
||||
|
||||
```bash
|
||||
strands update
|
||||
```
|
||||
|
||||
On the first interactive launch, the intro animation plays before the setup panel opens. Quickstart
|
||||
lets you choose providers and a model, then select tools, skills, MCP, and other capabilities before
|
||||
launching. Select or deselect all, or toggle individual capabilities. Customize setup also
|
||||
@@ -92,8 +98,8 @@ compiled output and skips all source-build behavior.
|
||||
## Customize the agent
|
||||
|
||||
Run `strands` or use `/setup` in chat to reopen the saved configuration. Quickstart and Customize
|
||||
edit the same profile. Import loads a harness from a file or zip, and Export saves the saved agent as a
|
||||
TypeScript or Python project.
|
||||
edit the same profile. Import loads a harness from a file or zip, and Resume returns to the configured
|
||||
harness. Use `/export` in chat to save the agent as a TypeScript or Python project.
|
||||
|
||||
In interactive chat, the agent can also inspect and change its own configuration with `strands_config`.
|
||||
For example: “Use high reasoning, disable shell and delegation, and keep responses concise.”
|
||||
|
||||
Generated
+9
-1
@@ -26,6 +26,7 @@
|
||||
"marked": "^18.0.9",
|
||||
"openai": "^6.45.0",
|
||||
"react": "19.2.8",
|
||||
"semver": "^7.8.5",
|
||||
"smol-toml": "1.7.1",
|
||||
"string-width": "^8.2.2",
|
||||
"tsx": "^4.23.13",
|
||||
@@ -39,6 +40,7 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.9.4",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/semver": "^7.8.0",
|
||||
"@types/yazl": "^3.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.62.1",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
@@ -2647,6 +2649,13 @@
|
||||
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
|
||||
"integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yazl": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/yazl/-/yazl-3.3.1.tgz",
|
||||
@@ -5942,7 +5951,6 @@
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
"marked": "^18.0.9",
|
||||
"openai": "^6.45.0",
|
||||
"react": "19.2.8",
|
||||
"semver": "^7.8.5",
|
||||
"smol-toml": "1.7.1",
|
||||
"string-width": "^8.2.2",
|
||||
"tsx": "^4.23.13",
|
||||
@@ -86,6 +87,7 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^25.9.4",
|
||||
"@types/react": "19.2.18",
|
||||
"@types/semver": "^7.8.0",
|
||||
"@types/yazl": "^3.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.62.1",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EFFORT_LEVELS, normalizeHarnessAgentConfig } from '@strands-agents/harn
|
||||
import { readCliVersion } from '../tui/package-version.js'
|
||||
|
||||
export interface ParsedArgs {
|
||||
update: boolean
|
||||
request: string | undefined
|
||||
oneShot: boolean
|
||||
acpServer: boolean
|
||||
@@ -34,6 +35,10 @@ export type CliRunMode = 'acp' | 'ink' | 'plain' | 'print'
|
||||
const DIRECT_CONFIG_FLAGS = ['name', 'description', 'effort', 'instructions', 'interventions'] as const
|
||||
|
||||
export function parseArgs(argv: string[]): ParsedArgs {
|
||||
if (argv[0] === 'update') {
|
||||
return parseUpdateArgs(argv.slice(1))
|
||||
}
|
||||
|
||||
const collect = (value: string, previous: string[]): string[] => [...previous, value]
|
||||
const program = new Command()
|
||||
program
|
||||
@@ -68,6 +73,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
.option('--mcp-config <path>', 'additional MCP configuration file (repeatable)', collect, [])
|
||||
.allowExcessArguments(false)
|
||||
.exitOverride()
|
||||
.addHelpText('after', '\nCommands:\n update update the globally installed CLI\n')
|
||||
|
||||
program.parse(argv, { from: 'user' })
|
||||
const opts = program.opts()
|
||||
@@ -75,6 +81,7 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
program.error('error: pass the initial request either positionally or with --prompt, not both.')
|
||||
}
|
||||
return {
|
||||
update: false,
|
||||
request: opts.prompt ?? program.args[0],
|
||||
oneShot: Boolean(opts.print),
|
||||
acpServer: Boolean(opts.acpServer),
|
||||
@@ -100,6 +107,42 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
}
|
||||
}
|
||||
|
||||
function parseUpdateArgs(argv: string[]): ParsedArgs {
|
||||
const program = new Command()
|
||||
program
|
||||
.name('strands update')
|
||||
.description('Update the globally installed Strands CLI.')
|
||||
.allowExcessArguments(false)
|
||||
.exitOverride()
|
||||
.parse(argv, { from: 'user' })
|
||||
|
||||
return {
|
||||
update: true,
|
||||
request: undefined,
|
||||
oneShot: false,
|
||||
acpServer: false,
|
||||
setup: false,
|
||||
agent: undefined,
|
||||
configSet: [],
|
||||
envFiles: [],
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
model: undefined,
|
||||
effort: undefined,
|
||||
instructions: undefined,
|
||||
builtinTools: undefined,
|
||||
builtinPlugins: undefined,
|
||||
caching: undefined,
|
||||
contextManager: undefined,
|
||||
session: undefined,
|
||||
sessionId: undefined,
|
||||
skills: undefined,
|
||||
memory: undefined,
|
||||
interventions: undefined,
|
||||
mcpConfig: [],
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply arbitrary portable config values, followed by the dedicated CLI flag shorthands. */
|
||||
export function agentConfig(args: ParsedArgs, defaults: HarnessAgentConfig): HarnessAgentConfig {
|
||||
const config = globalThis.structuredClone(defaults) as unknown as Record<string, unknown>
|
||||
|
||||
@@ -28,6 +28,12 @@ export async function main(argv: string[] = process.argv.slice(2)): Promise<void
|
||||
throw err
|
||||
}
|
||||
|
||||
if (args.update) {
|
||||
const { updateCli } = await import('./update.js')
|
||||
process.exitCode = await updateCli()
|
||||
return
|
||||
}
|
||||
|
||||
const stdinIsTty = stdin.isTTY === true
|
||||
const mode = selectRunMode(args, { stdinIsTty, stdoutIsTty: stdout.isTTY === true })
|
||||
initLogging(mode)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
import { readCliVersion } from '../tui/package-version.js'
|
||||
import { CLI_PACKAGE, compareVersions, publishedVersion } from '../tui/update-check.js'
|
||||
import { captureCommand, npmInvocation } from '../tui/npm.js'
|
||||
|
||||
export interface UpdateRunner {
|
||||
capture(command: string, args: string[]): Promise<string>
|
||||
inherit(command: string, args: string[]): Promise<number>
|
||||
}
|
||||
|
||||
export interface UpdateOptions {
|
||||
currentVersion?: string
|
||||
runner?: UpdateRunner
|
||||
output?: Pick<NodeJS.WriteStream, 'write'>
|
||||
errorOutput?: Pick<NodeJS.WriteStream, 'write'>
|
||||
platform?: NodeJS.Platform
|
||||
commandShell?: string
|
||||
}
|
||||
|
||||
/** Update the globally installed CLI through npm. */
|
||||
export async function updateCli(options: UpdateOptions = {}): Promise<number> {
|
||||
const currentVersion = options.currentVersion ?? readCliVersion()
|
||||
const output = options.output ?? process.stdout
|
||||
const errorOutput = options.errorOutput ?? process.stderr
|
||||
const runner = options.runner ?? nodeUpdateRunner
|
||||
const invocationOptions = {
|
||||
...(options.platform !== undefined ? { platform: options.platform } : {}),
|
||||
...(options.commandShell !== undefined ? { commandShell: options.commandShell } : {}),
|
||||
}
|
||||
|
||||
if (currentVersion.includes('development')) {
|
||||
errorOutput.write(
|
||||
'error: `strands update` is unavailable from a development checkout. Pull the latest source and run `npm run setup` instead.\n'
|
||||
)
|
||||
return 1
|
||||
}
|
||||
|
||||
try {
|
||||
const lookup = npmInvocation(['view', `${CLI_PACKAGE}@latest`, 'version', '--json'], invocationOptions)
|
||||
const latestVersion = publishedVersion(await runner.capture(lookup.command, lookup.args))
|
||||
const versionOrder = compareVersions(latestVersion, currentVersion)
|
||||
if (versionOrder === undefined) {
|
||||
throw new Error(`npm returned an invalid package version ${JSON.stringify(latestVersion)}`)
|
||||
}
|
||||
if (versionOrder !== 1) {
|
||||
output.write(
|
||||
versionOrder === 0
|
||||
? `Strands CLI ${currentVersion} is already up to date.\n`
|
||||
: `Strands CLI ${currentVersion} is newer than npm latest ${latestVersion}; no update was installed.\n`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
output.write(`Updating Strands CLI from ${currentVersion} to ${latestVersion}...\n`)
|
||||
const install = npmInvocation(['install', '--global', `${CLI_PACKAGE}@${latestVersion}`], invocationOptions)
|
||||
const exitCode = await runner.inherit(install.command, install.args)
|
||||
if (exitCode !== 0) {
|
||||
errorOutput.write(
|
||||
`error: Update failed with exit code ${exitCode}. Retry with \`npm install --global ${CLI_PACKAGE}@latest\`.\n`
|
||||
)
|
||||
return exitCode
|
||||
}
|
||||
output.write(`Updated Strands CLI to ${latestVersion}. Run \`strands\` again to use it.\n`)
|
||||
return 0
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err)
|
||||
errorOutput.write(`error: Unable to update the Strands CLI: ${detail}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
const nodeUpdateRunner: UpdateRunner = {
|
||||
async capture(command, args) {
|
||||
return captureCommand(command, args)
|
||||
},
|
||||
async inherit(command, args) {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
const child = spawn(command, args, { stdio: 'inherit' })
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`npm was terminated by ${signal}`))
|
||||
} else {
|
||||
resolve(code ?? 1)
|
||||
}
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export const LOCAL_COMMANDS: readonly LocalCommandSpec[] = [
|
||||
description: 'Browse, resume, or rename the current saved conversation',
|
||||
},
|
||||
{ name: 'tools', usage: '/tools', description: 'Choose the built-in tools the agent has' },
|
||||
{ name: 'skills', usage: '/skills', description: 'Browse available and active skills' },
|
||||
{ name: 'skills', usage: '/skills', description: 'Browse and run available skills' },
|
||||
{ name: 'mcp', usage: '/mcp', description: 'Show configured MCP servers and connection state' },
|
||||
{ name: 'permissions', usage: '/permissions [default|bypass]', description: 'Configure tool approvals' },
|
||||
{ name: 'voice', usage: '/voice [on|off|status]', description: 'Open bidirectional voice controls' },
|
||||
|
||||
@@ -799,7 +799,8 @@ export class ChatController implements ChatControllerApi {
|
||||
? this._selectEffort(row.value.slice('effort:'.length))
|
||||
: this._selectModel(row.value)
|
||||
case 'skills':
|
||||
return this._openSkillDetail(row.value)
|
||||
await this.submit(`$${row.value}`)
|
||||
return true
|
||||
case 'tasks':
|
||||
return row.value === BACKGROUND_TASK_WAIT_TOGGLE
|
||||
? this._toggleBackgroundTaskWaitMode()
|
||||
@@ -1406,8 +1407,12 @@ export class ChatController implements ChatControllerApi {
|
||||
this._panelStack.length = 0
|
||||
this._emit()
|
||||
try {
|
||||
if (await this._backend.compact()) {
|
||||
this._context = {}
|
||||
const context = await this._backend.compact()
|
||||
if (context) {
|
||||
this._context = { ...context }
|
||||
this._addNotice('success', 'Compacted older conversation context into a summary')
|
||||
} else {
|
||||
this._addNotice('delivered', 'Nothing to compact yet')
|
||||
}
|
||||
} catch (error) {
|
||||
this._openError('compaction failed', 'compact', errorMessage(error))
|
||||
@@ -1463,7 +1468,7 @@ export class ChatController implements ChatControllerApi {
|
||||
}
|
||||
}
|
||||
|
||||
private _openSkillDetail(name: string): boolean {
|
||||
openSkillDetail(name: string): boolean {
|
||||
const skill = this._skillDetails.get(name)
|
||||
if (!skill) {
|
||||
return false
|
||||
|
||||
@@ -271,7 +271,8 @@ export interface ChatBackend {
|
||||
removeAllowedPermission?(toolName: string): Promise<void>
|
||||
backgroundTasksWaitForCompletion?(): boolean | undefined
|
||||
setBackgroundTasksWaitForCompletion?(waitForCompletion: boolean): Promise<void>
|
||||
compact?(): Promise<boolean>
|
||||
/** Summarizes older conversation context and returns the new context usage, or undefined when nothing changed. */
|
||||
compact?(): Promise<ChatContextUsage | undefined>
|
||||
clear?(): Promise<void>
|
||||
hasReadyBackgroundResults?(): boolean
|
||||
streamBackgroundResults?(): AsyncGenerator<ChatEvent, ChatRunResult, undefined>
|
||||
@@ -383,6 +384,7 @@ export interface ChatPanel {
|
||||
| 'skills'
|
||||
| 'mcp'
|
||||
| 'agents'
|
||||
| 'rename'
|
||||
| 'permissions'
|
||||
| 'tools'
|
||||
| 'settings'
|
||||
@@ -517,6 +519,7 @@ export interface ChatControllerApi {
|
||||
dispose(): Promise<void>
|
||||
dismissPanel(): boolean
|
||||
activatePanelRow(row: ChatPanelRow): Promise<boolean>
|
||||
openSkillDetail(name: string): boolean
|
||||
openModelPanel(): Promise<void>
|
||||
openContextPanel(): void
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
export interface CommandInvocation {
|
||||
command: string
|
||||
args: string[]
|
||||
}
|
||||
|
||||
export interface NpmInvocationOptions {
|
||||
platform?: NodeJS.Platform
|
||||
commandShell?: string
|
||||
}
|
||||
|
||||
/** Build an npm invocation without passing argument arrays through Node's deprecated shell option. */
|
||||
export function npmInvocation(args: string[], options: NpmInvocationOptions = {}): CommandInvocation {
|
||||
if ((options.platform ?? process.platform) !== 'win32') {
|
||||
return { command: 'npm', args }
|
||||
}
|
||||
return {
|
||||
command: options.commandShell ?? process.env.ComSpec ?? 'cmd.exe',
|
||||
args: ['/d', '/s', '/c', windowsNpmCommand(args)],
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureCommand(command: string, args: string[], timeout?: number): Promise<string> {
|
||||
const { stdout } = await execFileAsync(command, args, {
|
||||
encoding: 'utf8',
|
||||
timeout,
|
||||
windowsHide: true,
|
||||
})
|
||||
return stdout
|
||||
}
|
||||
|
||||
export async function captureNpm(
|
||||
args: string[],
|
||||
options: NpmInvocationOptions & { timeout?: number } = {}
|
||||
): Promise<string> {
|
||||
const invocation = npmInvocation(args, options)
|
||||
return captureCommand(invocation.command, invocation.args, options.timeout)
|
||||
}
|
||||
|
||||
function windowsNpmCommand(args: string[]): string {
|
||||
if (args.some((arg) => !/^[A-Za-z0-9@/._:+-]+$/u.test(arg))) {
|
||||
throw new Error('npm argument contains characters that are unsafe for cmd.exe')
|
||||
}
|
||||
return ['npm.cmd', ...args].join(' ')
|
||||
}
|
||||
@@ -9,7 +9,14 @@ import { Message, type MessageData, type JSONValue, type ContentBlockData } from
|
||||
import type { HarnessAgentConfig } from '@strands-agents/harness'
|
||||
import { isAuthorized } from '@cedar-policy/cedar-wasm/nodejs'
|
||||
|
||||
import type { ChatBackend, ChatConversation, ChatEvent, ChatRunResult, ChatPermissionMode } from '../chat/types.js'
|
||||
import type {
|
||||
ChatBackend,
|
||||
ChatContextUsage,
|
||||
ChatConversation,
|
||||
ChatEvent,
|
||||
ChatRunResult,
|
||||
ChatPermissionMode,
|
||||
} from '../chat/types.js'
|
||||
import type { SetupChange } from '../agent-configuration.js'
|
||||
import type { ImportedAgentProject } from './import.js'
|
||||
import { prepareArchiveDependencies } from './archive.js'
|
||||
@@ -310,9 +317,9 @@ export class PythonBackend implements ChatBackend {
|
||||
await this._request({ type: 'reset', clear: true, sessionId: randomUUID() })
|
||||
}
|
||||
|
||||
async compact(): Promise<boolean> {
|
||||
async compact(): Promise<ChatContextUsage | undefined> {
|
||||
const result = await this._request({ type: 'compact' })
|
||||
return result.stopReason === 'compacted'
|
||||
return result.stopReason === 'compacted' ? { ...result.context } : undefined
|
||||
}
|
||||
|
||||
async activateSkill(name: string): Promise<SkillInfo | undefined> {
|
||||
|
||||
@@ -33,6 +33,7 @@ from strands_harness.defaults import DEFAULT_MODEL
|
||||
|
||||
channel = os.fdopen(3, "w", buffering=1)
|
||||
pending_permissions = {}
|
||||
COMPACT_PRESERVE_RECENT_MESSAGES = 2
|
||||
|
||||
|
||||
def encode(value):
|
||||
@@ -55,6 +56,14 @@ def emit(event):
|
||||
send({"type": "event", "event": event})
|
||||
|
||||
|
||||
def compacted_tokens(reported, estimated_before, estimated_after):
|
||||
"""Offline token estimates can overcount the system prompt and tool specs by a wide margin, so a
|
||||
model-reported size minus the estimated reduction tracks the next reported size more closely."""
|
||||
if reported is None or estimated_before is None or estimated_after is None:
|
||||
return estimated_after
|
||||
return max(0, reported - max(0, estimated_before - estimated_after))
|
||||
|
||||
|
||||
async def authorize_tool(event):
|
||||
tool_use = event.tool_use
|
||||
if tool_use["name"] == "strands_config":
|
||||
@@ -263,6 +272,7 @@ class Runtime:
|
||||
raise
|
||||
self.agent = candidate
|
||||
self.options = options
|
||||
self.reported_context_tokens = None
|
||||
if previous is not None and previous is not candidate:
|
||||
await self.cleanup(previous)
|
||||
|
||||
@@ -416,6 +426,42 @@ class Runtime:
|
||||
def result(self, result, *, reload=False):
|
||||
send({"type": "result", "result": result, "state": self.state(), "reload": reload})
|
||||
|
||||
async def estimate_context_tokens(self):
|
||||
try:
|
||||
return await self.agent.model.count_tokens(
|
||||
self.agent.messages,
|
||||
tool_specs=self.agent.tool_registry.get_all_tool_specs(),
|
||||
system_prompt=self.agent.system_prompt,
|
||||
system_prompt_content=self.agent._system_prompt_content,
|
||||
)
|
||||
except Exception:
|
||||
# The meter stays empty until the next model response reports usage.
|
||||
return None
|
||||
|
||||
async def compact(self):
|
||||
from strands.agent.conversation_manager import SummarizingConversationManager
|
||||
|
||||
if len(self.agent.messages) <= COMPACT_PRESERVE_RECENT_MESSAGES:
|
||||
return None
|
||||
before = [id(message) for message in self.agent.messages]
|
||||
estimated_before = await self.estimate_context_tokens()
|
||||
manager = SummarizingConversationManager(
|
||||
summary_ratio=0.8, preserve_recent_messages=COMPACT_PRESERVE_RECENT_MESSAGES
|
||||
)
|
||||
await asyncio.to_thread(manager.reduce_context, self.agent)
|
||||
# A proactive reduce_context logs summarization failures instead of raising.
|
||||
if [id(message) for message in self.agent.messages] == before:
|
||||
raise RuntimeError("The model did not return a usable summary. Try /compact again.")
|
||||
await self.save()
|
||||
self.reported_context_tokens = compacted_tokens(
|
||||
self.reported_context_tokens, estimated_before, await self.estimate_context_tokens()
|
||||
)
|
||||
context = {
|
||||
"currentTokens": self.reported_context_tokens,
|
||||
"contextWindow": self.agent.model.context_window_limit,
|
||||
}
|
||||
return {key: value for key, value in context.items() if value is not None}
|
||||
|
||||
async def turn(self, prompt):
|
||||
self.cancel_signal.clear()
|
||||
self.reload_requested = False
|
||||
@@ -454,6 +500,11 @@ class Runtime:
|
||||
if result is None:
|
||||
raise RuntimeError("The Python agent finished without a result")
|
||||
if result.stop_reason != "interrupt" or not result.interrupts:
|
||||
self.reported_context_tokens = (
|
||||
result.projected_context_size
|
||||
if result.projected_context_size is not None
|
||||
else result.context_size
|
||||
)
|
||||
context = {
|
||||
"currentTokens": result.context_size,
|
||||
"projectedTokens": result.projected_context_size,
|
||||
@@ -585,13 +636,11 @@ class Runtime:
|
||||
await self.build(options, snapshot)
|
||||
self.result({"stopReason": "reset"})
|
||||
elif kind == "compact":
|
||||
from strands.agent.conversation_manager import SummarizingConversationManager
|
||||
|
||||
manager = SummarizingConversationManager(summary_ratio=0.8, preserve_recent_messages=2)
|
||||
before = len(self.agent.messages)
|
||||
await asyncio.to_thread(manager.reduce_context, self.agent)
|
||||
await self.save()
|
||||
self.result({"stopReason": "compacted" if len(self.agent.messages) < before else "unchanged"})
|
||||
context = await self.compact()
|
||||
if context is None:
|
||||
self.result({"stopReason": "unchanged"})
|
||||
else:
|
||||
self.result({"stopReason": "compacted", "context": context})
|
||||
elif kind == "skill":
|
||||
skills = self.skills()
|
||||
if not skills:
|
||||
|
||||
@@ -25,15 +25,37 @@ export async function persistContext(
|
||||
result: AgentResult,
|
||||
context: ChatContextUsage | undefined
|
||||
): Promise<void> {
|
||||
if (!context || Object.keys(context).length === 0) {
|
||||
if (!context || result.lastMessage?.trackingId !== runtime.agent.messages?.at(-1)?.trackingId) {
|
||||
return
|
||||
}
|
||||
const message = runtime.agent.messages?.at(-1)
|
||||
const fingerprint = contextFingerprint(runtime, scope)
|
||||
if (message?.role !== 'assistant' || result.lastMessage?.trackingId !== message.trackingId || !fingerprint) {
|
||||
if (!storeContext(runtime, scope, context)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await runtime.agent.sessionManager?.saveSnapshot({
|
||||
target: runtime.agent,
|
||||
isLatest: true,
|
||||
})
|
||||
} catch {
|
||||
// A meter persistence failure must not turn a completed model response into a failed turn.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the context usage on the latest assistant message so a resumed session restores the meter.
|
||||
* The caller saves the session snapshot.
|
||||
*/
|
||||
export function storeContext(
|
||||
runtime: AgentModelRuntime,
|
||||
scope: string | undefined,
|
||||
context: ChatContextUsage
|
||||
): boolean {
|
||||
const messages = runtime.agent.messages
|
||||
const message = messages?.at(-1)
|
||||
const fingerprint = contextFingerprint(runtime, scope)
|
||||
if (Object.keys(context).length === 0 || message?.role !== 'assistant' || !fingerprint) {
|
||||
return false
|
||||
}
|
||||
for (let index = messages.length - 2; index >= 0; index--) {
|
||||
if (clearStoredContext(messages[index])) {
|
||||
break
|
||||
@@ -49,14 +71,7 @@ export async function persistContext(
|
||||
},
|
||||
},
|
||||
}
|
||||
try {
|
||||
await runtime.agent.sessionManager?.saveSnapshot({
|
||||
target: runtime.agent,
|
||||
isLatest: true,
|
||||
})
|
||||
} catch {
|
||||
// A meter persistence failure must not turn a completed model response into a failed turn.
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function contextFingerprint(runtime: AgentModelRuntime, scope: string | undefined): string | undefined {
|
||||
|
||||
@@ -183,7 +183,11 @@ export class ConversationManager implements ChatControllerApi {
|
||||
return this._forkConversation(unquote(argument))
|
||||
}
|
||||
if (command === 'rename') {
|
||||
this._renameConversation(unquote(argument))
|
||||
if (argument) {
|
||||
this._renameConversation(unquote(argument))
|
||||
} else {
|
||||
this._openRenamePanel()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if (command === 'voice') {
|
||||
@@ -261,6 +265,10 @@ export class ConversationManager implements ChatControllerApi {
|
||||
}
|
||||
|
||||
async activatePanelRow(row: ChatPanelRow): Promise<boolean> {
|
||||
if (this._panel?.kind === 'rename' && row.value?.startsWith('rename:')) {
|
||||
this._renameConversation(row.value.slice('rename:'.length))
|
||||
return true
|
||||
}
|
||||
if (this._panel?.kind === 'voice') {
|
||||
return row.value ? this._voice.handlePanelAction(row.value) : false
|
||||
}
|
||||
@@ -284,6 +292,10 @@ export class ConversationManager implements ChatControllerApi {
|
||||
this._active.controller.openContextPanel()
|
||||
}
|
||||
|
||||
openSkillDetail(name: string): boolean {
|
||||
return this._active.controller.openSkillDetail(name)
|
||||
}
|
||||
|
||||
private get _active(): ConversationRecord {
|
||||
return this._conversations.get(this._activeId)!
|
||||
}
|
||||
@@ -440,6 +452,13 @@ export class ConversationManager implements ChatControllerApi {
|
||||
this._emit()
|
||||
}
|
||||
|
||||
private _openRenamePanel(): void {
|
||||
this._panel = this._makePanel('rename', 'Rename agent', [
|
||||
{ label: 'Current name', description: this._active.title },
|
||||
])
|
||||
this._emit()
|
||||
}
|
||||
|
||||
private _openAgentsPanel(emit = true): void {
|
||||
const existingId =
|
||||
this._panel?.kind === 'agents' && this._panel.title === AGENTS_PANEL_TITLE ? this._panel.id : undefined
|
||||
|
||||
@@ -31,7 +31,7 @@ import type { AgentModelRuntime } from './model/runtime.js'
|
||||
import { contextWindowLimit } from './model/context.js'
|
||||
import type { CedarPermissions, ToolPermissionBroker } from './permissions/policy.js'
|
||||
import { ShellRunner } from './chat/shell.js'
|
||||
import { clearStoredContext, persistContext, restoreContext } from './session/context.js'
|
||||
import { clearStoredContext, persistContext, restoreContext, storeContext } from './session/context.js'
|
||||
import type { LiveSteering } from './steering.js'
|
||||
import { latestRootModelUsage, RunUsage } from '../usage.js'
|
||||
|
||||
@@ -43,6 +43,7 @@ interface HarnessTodo {
|
||||
|
||||
const TASK_REFRESH_INTERVAL_MS = 200
|
||||
const ALWAYS_BACKGROUND_TOOL_NAMES = new Set(['subagent'])
|
||||
const COMPACT_PRESERVE_RECENT_MESSAGES = 2
|
||||
|
||||
interface StrandsChatBackendOptions {
|
||||
sourceDefinition?: Omit<NonNullable<ChatConversation['sourceSelection']>, 'selected'>
|
||||
@@ -196,20 +197,50 @@ export class StrandsChatBackend implements ChatBackend {
|
||||
return this._runtime.setBackgroundTasksWaitForCompletion(waitForCompletion)
|
||||
}
|
||||
|
||||
async compact(): Promise<boolean> {
|
||||
async compact(): Promise<ChatContextUsage | undefined> {
|
||||
const agent = this._runtime.agent
|
||||
if (agent.messages.length <= COMPACT_PRESERVE_RECENT_MESSAGES) {
|
||||
return undefined
|
||||
}
|
||||
const reported = this.contextUsage()
|
||||
const estimatedBefore = await this._estimateContextTokens()
|
||||
const manager = new SummarizingConversationManager({
|
||||
summaryRatio: 0.8,
|
||||
preserveRecentMessages: 2,
|
||||
preserveRecentMessages: COMPACT_PRESERVE_RECENT_MESSAGES,
|
||||
})
|
||||
const reduced = await manager.reduce({ agent: this._runtime.agent, model: this._runtime.agent.model })
|
||||
if (reduced) {
|
||||
clearStoredContext(this._runtime.agent.messages.at(-1))
|
||||
await this._runtime.agent.sessionManager?.saveSnapshot({
|
||||
target: this._runtime.agent,
|
||||
isLatest: true,
|
||||
// A proactive reduce reports summarization failures by returning false rather than throwing.
|
||||
if (!(await manager.reduce({ agent, model: agent.model }))) {
|
||||
throw new Error('The model did not return a usable summary. Try /compact again.')
|
||||
}
|
||||
const currentTokens = compactedTokens(
|
||||
reported?.projectedTokens ?? reported?.currentTokens,
|
||||
estimatedBefore,
|
||||
await this._estimateContextTokens()
|
||||
)
|
||||
const contextWindow = (await this._contextWindow?.()) ?? contextWindowLimit(agent.model)
|
||||
const context: ChatContextUsage = {
|
||||
...(currentTokens !== undefined ? { currentTokens } : {}),
|
||||
...(contextWindow !== undefined ? { contextWindow } : {}),
|
||||
}
|
||||
if (!storeContext(this._runtime, this._contextScope, context)) {
|
||||
clearStoredContext(agent.messages.at(-1))
|
||||
}
|
||||
await agent.sessionManager?.saveSnapshot({ target: agent, isLatest: true })
|
||||
return context
|
||||
}
|
||||
|
||||
/** Estimates the context size of the current conversation without a model invocation. */
|
||||
private async _estimateContextTokens(): Promise<number | undefined> {
|
||||
const agent = this._runtime.agent
|
||||
try {
|
||||
return await agent.model.countTokens(agent.messages, {
|
||||
...(agent.systemPrompt !== undefined && { systemPrompt: agent.systemPrompt }),
|
||||
toolSpecs: agent.tools.map((tool) => tool.toolSpec),
|
||||
})
|
||||
} catch {
|
||||
// The meter stays empty until the next model response reports usage.
|
||||
return undefined
|
||||
}
|
||||
return reduced
|
||||
}
|
||||
|
||||
clear(): Promise<void> {
|
||||
@@ -434,6 +465,21 @@ export class StrandsChatBackend implements ChatBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offline token estimates can overcount the system prompt and tool specs by a wide margin, so a
|
||||
* model-reported size minus the estimated reduction tracks the next reported size more closely.
|
||||
*/
|
||||
function compactedTokens(
|
||||
reported: number | undefined,
|
||||
estimatedBefore: number | undefined,
|
||||
estimatedAfter: number | undefined
|
||||
): number | undefined {
|
||||
if (reported === undefined || estimatedBefore === undefined || estimatedAfter === undefined) {
|
||||
return estimatedAfter
|
||||
}
|
||||
return Math.max(0, reported - Math.max(0, estimatedBefore - estimatedAfter))
|
||||
}
|
||||
|
||||
function readTodos(agent: Agent): ChatTask[] {
|
||||
const value = agent.appState.get('todos')
|
||||
if (!Array.isArray(value)) {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { compare, valid } from 'semver'
|
||||
|
||||
import { readCliVersion } from './package-version.js'
|
||||
import { captureNpm } from './npm.js'
|
||||
|
||||
export const CLI_PACKAGE = '@strands-agents/cli'
|
||||
|
||||
export interface UpdateCheckOptions {
|
||||
currentVersion?: string
|
||||
resolveLatest?: () => Promise<string>
|
||||
}
|
||||
|
||||
/** Resolves the latest published CLI version when it is newer than this one, or undefined when unknown. */
|
||||
export async function availableCliUpdate(options: UpdateCheckOptions = {}): Promise<string | undefined> {
|
||||
const currentVersion = options.currentVersion ?? readCliVersion()
|
||||
if (currentVersion.includes('development')) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const latestVersion = await (options.resolveLatest ?? resolveLatestCliVersion)()
|
||||
return compareVersions(latestVersion, currentVersion) === 1 ? latestVersion : undefined
|
||||
} catch {
|
||||
// Setup stays usable offline; the notice only appears when npm resolves the configured registry.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveLatestCliVersion(): Promise<string> {
|
||||
return publishedVersion(await captureNpm(['view', `${CLI_PACKAGE}@latest`, 'version', '--json'], { timeout: 3_000 }))
|
||||
}
|
||||
|
||||
export function publishedVersion(output: string): string {
|
||||
const parsed = JSON.parse(output) as unknown
|
||||
if (typeof parsed !== 'string' || !parsed.trim()) {
|
||||
throw new Error('npm returned an invalid package version')
|
||||
}
|
||||
return parsed.trim()
|
||||
}
|
||||
|
||||
export function isNewerVersion(candidate: string, current: string): boolean {
|
||||
return compareVersions(candidate, current) === 1
|
||||
}
|
||||
|
||||
/** Orders two versions by semver precedence, or undefined when either is not a valid version. */
|
||||
export function compareVersions(candidate: string, current: string): -1 | 0 | 1 | undefined {
|
||||
return valid(candidate) && valid(current) ? compare(candidate, current) : undefined
|
||||
}
|
||||
@@ -787,6 +787,27 @@ export function ChatApp({
|
||||
return
|
||||
}
|
||||
|
||||
if (snapshot.panel.kind === 'rename') {
|
||||
if (key.escape) {
|
||||
controller.dismissPanel()
|
||||
} else if (key.return) {
|
||||
const name = panelQuery.trim()
|
||||
if (name) {
|
||||
void activateRow({ label: 'Rename', description: name, value: `rename:${name}` })
|
||||
}
|
||||
} else if (key.backspace || key.delete || character === '\u007f') {
|
||||
setPanelQuery(graphemes(panelQuery).slice(0, -1).join(''))
|
||||
} else if (key.ctrl && character === 'u') {
|
||||
setPanelQuery('')
|
||||
} else if (!key.ctrl && !key.meta && !key.super && character) {
|
||||
const clean = sanitizeTerminalText(character).replaceAll('\n', ' ')
|
||||
if (clean) {
|
||||
setPanelQuery((query) => query + clean)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const rows = panelRows ?? []
|
||||
const rowCapacity =
|
||||
snapshot.panel.kind === 'agents'
|
||||
@@ -1004,6 +1025,13 @@ export function ChatApp({
|
||||
return
|
||||
}
|
||||
}
|
||||
if (key.rightArrow && snapshot.panel.kind === 'skills') {
|
||||
const selected = rows[Math.min(panelSelectionRef.current, rows.length - 1)]
|
||||
if (selected?.value) {
|
||||
controller.openSkillDetail(selected.value)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.return) {
|
||||
const selected = rows[Math.min(panelSelectionRef.current, rows.length - 1)]
|
||||
if (selected?.value) {
|
||||
|
||||
@@ -151,7 +151,8 @@ function ChatViewContent({
|
||||
const resolvedCommandAssistance =
|
||||
commandAssistance ?? (suggestions === undefined ? commandAssistanceForInput(input) : undefined)
|
||||
const resolvedSuggestions = suggestions ?? resolvedCommandAssistance?.completions ?? []
|
||||
const hasActivity = snapshot.completedTurns.length > 0 || snapshot.activeTurn !== undefined
|
||||
const hasActivity =
|
||||
snapshot.completedTurns.length > 0 || snapshot.activeTurn !== undefined || snapshot.notices.length > 0
|
||||
const startupCovered = Boolean(snapshot.panel) || resolvedCommandAssistance !== undefined
|
||||
const showQueueStatus = snapshot.queuedPrompts.length > 0 || snapshot.status === 'interrupting'
|
||||
const showVoiceStatus = snapshot.voice !== undefined && snapshot.voice.status !== 'off'
|
||||
|
||||
@@ -15,21 +15,25 @@ export function PanelHelpFooter({ width }: { width: number }): ReactElement | nu
|
||||
? '↑↓ · Enter choose · Esc deny'
|
||||
: detail
|
||||
? '↑↓ scroll · Esc back'
|
||||
: panel.kind === 'effort'
|
||||
? '←→ change · Enter done · Esc close'
|
||||
: !actionable
|
||||
? 'Esc close'
|
||||
: panel.kind === 'settings'
|
||||
? width < 42
|
||||
? '↑↓ move · ←→ change · Esc back'
|
||||
: '↑↓ · ←→ change · Tab category · Esc back'
|
||||
: panel.kind === 'models'
|
||||
? 'Tab · Enter choose · Esc back'
|
||||
: panel.kind === 'tools' || panel.kind === 'permissions'
|
||||
? '↑↓ · Enter toggle · Esc save'
|
||||
: panel.filters?.length
|
||||
? 'Tab category · Enter · Esc back'
|
||||
: '↑↓ · Enter open · Esc back'
|
||||
: panel.kind === 'rename'
|
||||
? 'Enter save · Ctrl+U clear · Esc cancel'
|
||||
: panel.kind === 'effort'
|
||||
? '←→ change · Enter done · Esc close'
|
||||
: !actionable
|
||||
? 'Esc close'
|
||||
: panel.kind === 'settings'
|
||||
? width < 42
|
||||
? '↑↓ move · ←→ change · Esc back'
|
||||
: '↑↓ · ←→ change · Tab category · Esc back'
|
||||
: panel.kind === 'models'
|
||||
? 'Tab · Enter choose · Esc back'
|
||||
: panel.kind === 'tools' || panel.kind === 'permissions'
|
||||
? '↑↓ · Enter toggle · Esc save'
|
||||
: panel.kind === 'skills'
|
||||
? '↑↓ · Enter run · → details · Esc back'
|
||||
: panel.filters?.length
|
||||
? 'Tab category · Enter · Esc back'
|
||||
: '↑↓ · Enter open · Esc back'
|
||||
return (
|
||||
<Box width={Math.max(1, width)} height={1} flexShrink={0}>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { AgentsPanel } from './agents-panel.js'
|
||||
import { ExportPanel } from './export-panel.js'
|
||||
import { EffortSlider, ModelPicker } from './model-panel.js'
|
||||
import { RenamePanel } from './rename-panel.js'
|
||||
import { SessionsPanel } from './sessions-panel.js'
|
||||
import { PanelItemHeader, PanelOverlay, PanelTitle } from './panel-components.js'
|
||||
import { SettingsControl, SettingsPanel } from './settings-panel.js'
|
||||
@@ -117,15 +118,17 @@ export function ResourcePanel({
|
||||
? 112
|
||||
: panel.kind === 'agents'
|
||||
? 112
|
||||
: checklist
|
||||
? 96
|
||||
: panel.kind === 'context' || panel.kind === 'effort'
|
||||
? 52
|
||||
: panel.kind === 'permission' && panel.diff
|
||||
? 100
|
||||
: panel.kind === 'permission' || panel.kind === 'error'
|
||||
? 68
|
||||
: 84
|
||||
: panel.kind === 'rename'
|
||||
? 60
|
||||
: checklist
|
||||
? 96
|
||||
: panel.kind === 'context' || panel.kind === 'effort'
|
||||
? 52
|
||||
: panel.kind === 'permission' && panel.diff
|
||||
? 100
|
||||
: panel.kind === 'permission' || panel.kind === 'error'
|
||||
? 68
|
||||
: 84
|
||||
const width = Math.max(1, Math.min(preferredWidth, terminalWidth - 4))
|
||||
const color = panel.kind === 'error' ? 'red' : accent
|
||||
|
||||
@@ -320,6 +323,17 @@ export function ResourcePanel({
|
||||
<AgentsPanel {...rowProps} height={Math.max(5, terminalHeight - 8)} columns={agentGridColumns(terminalWidth)} />
|
||||
)
|
||||
}
|
||||
if (panel.kind === 'rename') {
|
||||
return (
|
||||
<RenamePanel
|
||||
panel={panel}
|
||||
value={query}
|
||||
width={width}
|
||||
animateCursor={settings.animations}
|
||||
{...(onPanelElement ? { onPanelElement } : {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (panel.kind === 'export') {
|
||||
return <ExportPanel {...rowProps} width={Math.min(68, width)} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { DOMElement } from 'ink'
|
||||
|
||||
import type { ChatPanel } from '../chat/types.js'
|
||||
import { graphemes } from '../terminal/composer.js'
|
||||
import { PanelOverlay, PanelTitle } from './panel-components.js'
|
||||
import { EditableText } from './text-input.js'
|
||||
import { Box, Text, useTheme } from './theme.js'
|
||||
|
||||
export function RenamePanel({
|
||||
panel,
|
||||
value,
|
||||
width,
|
||||
animateCursor,
|
||||
onPanelElement,
|
||||
}: {
|
||||
panel: ChatPanel
|
||||
value: string
|
||||
width: number
|
||||
animateCursor: boolean
|
||||
onPanelElement?: (element: DOMElement | null) => void
|
||||
}): ReactElement {
|
||||
const { accent, surface } = useTheme()
|
||||
const currentName = panel.rows[0]?.description
|
||||
return (
|
||||
<PanelOverlay width={width} {...(onPanelElement ? { onElement: onPanelElement } : {})}>
|
||||
<Box paddingX={1} flexDirection="column">
|
||||
<PanelTitle title={panel.title} color={accent} />
|
||||
{currentName ? (
|
||||
<Text dimColor wrap="truncate-end">
|
||||
Current name: {currentName}
|
||||
</Text>
|
||||
) : null}
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>New name</Text>
|
||||
</Box>
|
||||
<Box paddingX={1} height={3} alignItems="center" backgroundColor={surface}>
|
||||
<EditableText
|
||||
value={value}
|
||||
cursor={graphemes(value).length}
|
||||
width={Math.max(1, width - 6)}
|
||||
active
|
||||
animate={animateCursor}
|
||||
placeholder="Enter a new name"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</PanelOverlay>
|
||||
)
|
||||
}
|
||||
@@ -22,8 +22,7 @@ import {
|
||||
type CliConfigStore,
|
||||
type ProviderId,
|
||||
} from '../../config.js'
|
||||
import { importAgentProject, type AgentProjectLanguage } from '../../project/import.js'
|
||||
import { exportFileName, exportSavedAgent } from '../../project/export.js'
|
||||
import { importAgentProject } from '../../project/import.js'
|
||||
import { configurationFromStore, type SetupChange } from '../../agent-configuration.js'
|
||||
import type { ChatSettings } from '../../chat/types.js'
|
||||
import type { SettingsCategory } from '../../settings.js'
|
||||
@@ -31,12 +30,7 @@ import { DEFAULT_SETTINGS_CATEGORY, SETTINGS_CATEGORIES } from '../../settings.j
|
||||
import { parseMouseInput } from '../../terminal/mouse-input.js'
|
||||
import { emptyEditor, graphemes, reduceInputSequence } from '../../terminal/composer.js'
|
||||
import { errorMessage, sanitizeTerminalText } from '../../terminal/sanitize.js'
|
||||
import {
|
||||
canChooseDirectory,
|
||||
chooseAgentProject,
|
||||
chooseDirectory,
|
||||
chooseSaveFile,
|
||||
} from '../../terminal/directory-picker.js'
|
||||
import { canChooseDirectory, chooseAgentProject, chooseDirectory } from '../../terminal/directory-picker.js'
|
||||
import { setTerminalMouseMotion } from '../../terminal/terminal.js'
|
||||
import {
|
||||
elementAtMouse,
|
||||
@@ -68,7 +62,6 @@ import {
|
||||
APPEARANCE_STEP,
|
||||
appearanceSettings,
|
||||
CUSTOMIZE_STEPS,
|
||||
exportRows,
|
||||
OPENING_CHOICES,
|
||||
rowsForStep,
|
||||
setupStepProgress,
|
||||
@@ -84,6 +77,7 @@ import { useProviderDiscovery, useProviderDiscoveryEffects } from './use-provide
|
||||
import type { AppearanceSettings, EditableField, SelectOption, SetupDraft, SetupFlow, WizardRow } from './types.js'
|
||||
import { importPathCompletions } from './path-completion.js'
|
||||
import { SetupProgress } from './progress.js'
|
||||
import { availableCliUpdate } from '../../update-check.js'
|
||||
|
||||
type SetupAction = 'back' | 'next' | 'settings' | `browse:${number}`
|
||||
type SetupControl =
|
||||
@@ -118,6 +112,7 @@ function SetupWizardContent({
|
||||
onComplete,
|
||||
onCancel,
|
||||
initialSettings,
|
||||
checkForUpdate = availableCliUpdate,
|
||||
}: {
|
||||
appearance: AppearanceSettings
|
||||
setAppearance: Dispatch<SetStateAction<AppearanceSettings>>
|
||||
@@ -127,6 +122,8 @@ function SetupWizardContent({
|
||||
onComplete(change?: SetupChange): void
|
||||
onCancel?(exitCode: 0 | 130): void
|
||||
initialSettings?: Partial<ChatSettings>
|
||||
/** Resolves a newer published CLI version to announce on the opening menu. */
|
||||
checkForUpdate?(): Promise<string | undefined>
|
||||
}): ReactElement {
|
||||
const { stdout } = useStdout()
|
||||
const { columns, rows: terminalRows } = useWindowSize()
|
||||
@@ -182,12 +179,10 @@ function SetupWizardContent({
|
||||
})
|
||||
const [profileBaseDir] = useState<string | null | undefined>(() => config.snapshot().profileBaseDir)
|
||||
const [importPath, setImportPath] = useState('')
|
||||
const [exportLanguage, setExportLanguage] = useState<AgentProjectLanguage>('typescript')
|
||||
const [exportPath, setExportPath] = useState('')
|
||||
const [exportedPath, setExportedPath] = useState<string>()
|
||||
const [editing, setEditing] = useState<{ field: EditableField; value: string; cursor?: number }>()
|
||||
const [pathCompletionIndex, setPathCompletionIndex] = useState(-1)
|
||||
const [error, setError] = useState<string>()
|
||||
const [availableUpdate, setAvailableUpdate] = useState<string>()
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [choosingDirectory, setChoosingDirectory] = useState(false)
|
||||
const choosingDirectoryRef = useRef(false)
|
||||
@@ -249,6 +244,20 @@ function SetupWizardContent({
|
||||
useEffect(() => {
|
||||
setPathCompletionIndex(-1)
|
||||
}, [pathEditing?.value])
|
||||
useEffect(() => {
|
||||
if (appearanceOnly) {
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
void checkForUpdate().then((version) => {
|
||||
if (active) {
|
||||
setAvailableUpdate(version)
|
||||
}
|
||||
})
|
||||
return (): void => {
|
||||
active = false
|
||||
}
|
||||
}, [appearanceOnly, checkForUpdate])
|
||||
const isAppearance = step === APPEARANCE_STEP
|
||||
const isSettings = isAppearance && settingsReturn !== undefined
|
||||
const progress = appearanceOnly || isSettings ? undefined : setupStepProgress(flow, step)
|
||||
@@ -308,9 +317,8 @@ function SetupWizardContent({
|
||||
const isCapabilities = isTools || isPlugins
|
||||
const isPermissions = flow === 'customize' && step === 6
|
||||
const isImport = flow === 'import' && step === 1
|
||||
const isExport = flow === 'export' && step === 1
|
||||
const hasExternalActions =
|
||||
!isSettings && (isProviderSetup || isImport || isExport || isCapabilities || isAppearance || flow === 'customize')
|
||||
!isSettings && (isProviderSetup || isImport || isCapabilities || isAppearance || flow === 'customize')
|
||||
const bubbleWidth = Math.max(1, Math.min(isProviderSetup ? 144 : 112, width - 2))
|
||||
const lockupWidth = Math.max(1, width - 2)
|
||||
const brandFrame = setupBrandFrame(lockupWidth, height)
|
||||
@@ -320,7 +328,7 @@ function SetupWizardContent({
|
||||
const openingColumns = lockupWidth >= 64 ? 2 : 1
|
||||
const openingRows = Math.ceil(OPENING_CHOICES.length / openingColumns)
|
||||
const openingRowGap = openingColumns === 2 ? 2 : 1
|
||||
const openingContentHeight = height - brandHeight - navigationHeight - Number(error !== undefined)
|
||||
const openingContentHeight = height - brandHeight - navigationHeight - (error ? 2 : 0)
|
||||
const openingTopGap =
|
||||
openingContentHeight >= openingRows * (openingColumns === 2 ? 9 : 5) + (openingRows - 1) * openingRowGap + 3
|
||||
? 3
|
||||
@@ -384,17 +392,6 @@ function SetupWizardContent({
|
||||
[readyProviders, unavailableCredentialProvider]
|
||||
)
|
||||
|
||||
const agentName = draft.profile.name
|
||||
const changeExportLanguage = useCallback(
|
||||
(language: AgentProjectLanguage): void => {
|
||||
setExportLanguage(language)
|
||||
setExportedPath(undefined)
|
||||
setExportPath((path) =>
|
||||
path === exportFileName(agentName, exportLanguage) ? exportFileName(agentName, language) : path
|
||||
)
|
||||
},
|
||||
[agentName, exportLanguage]
|
||||
)
|
||||
const rows = useMemo<WizardRow[]>(() => {
|
||||
const stepRows = isAppearance
|
||||
? wizardSettingsRows(
|
||||
@@ -403,27 +400,25 @@ function SetupWizardContent({
|
||||
openAppearance,
|
||||
'all'
|
||||
).filter(({ section }) => section === settingsCategory)
|
||||
: isExport
|
||||
? exportRows(exportLanguage, exportPath, exportedPath, changeExportLanguage, setEditing)
|
||||
: rowsForStep(
|
||||
step,
|
||||
flow,
|
||||
draft,
|
||||
importPath,
|
||||
providerEnvironment,
|
||||
detectedEnvironment,
|
||||
quickstartProvider,
|
||||
setupReadyProviders,
|
||||
awsDiscovery,
|
||||
ollamaDiscovery,
|
||||
setDraft,
|
||||
updateProfile,
|
||||
setEditing,
|
||||
liteLlmDiscovery,
|
||||
credentialRejectedProvider,
|
||||
credentialValidationProvider,
|
||||
providerModels.error
|
||||
)
|
||||
: rowsForStep(
|
||||
step,
|
||||
flow,
|
||||
draft,
|
||||
importPath,
|
||||
providerEnvironment,
|
||||
detectedEnvironment,
|
||||
quickstartProvider,
|
||||
setupReadyProviders,
|
||||
awsDiscovery,
|
||||
ollamaDiscovery,
|
||||
setDraft,
|
||||
updateProfile,
|
||||
setEditing,
|
||||
liteLlmDiscovery,
|
||||
credentialRejectedProvider,
|
||||
credentialValidationProvider,
|
||||
providerModels.error
|
||||
)
|
||||
return isProviderSetup
|
||||
? [
|
||||
...stepRows,
|
||||
@@ -457,11 +452,6 @@ function SetupWizardContent({
|
||||
draft,
|
||||
flow,
|
||||
importPath,
|
||||
isExport,
|
||||
exportLanguage,
|
||||
exportPath,
|
||||
exportedPath,
|
||||
changeExportLanguage,
|
||||
ollamaDiscovery,
|
||||
liteLlmDiscovery,
|
||||
providerEnvironment,
|
||||
@@ -478,8 +468,8 @@ function SetupWizardContent({
|
||||
const hasSelectedModel = deselectedModel !== draft.profile.model
|
||||
const canContinue =
|
||||
step !== 1 ||
|
||||
(flow === 'import' || flow === 'export'
|
||||
? (flow === 'import' ? importPath : exportPath).trim().length > 0
|
||||
(flow === 'import'
|
||||
? importPath.trim().length > 0
|
||||
: isProviderSetup
|
||||
? hasSelectedModel &&
|
||||
selectedModelProvider === quickstartProvider &&
|
||||
@@ -538,11 +528,10 @@ function SetupWizardContent({
|
||||
? Math.min(9 + Math.max(0, rows.length - 3) * rowHeight + extraRows, availableBubbleHeight)
|
||||
: isAppearance
|
||||
? availableBubbleHeight
|
||||
: (flow === 'customize' && !isProviderSetup) || isExport
|
||||
: flow === 'customize' && !isProviderSetup
|
||||
? Math.min(rows.length * rowHeight + 3 + extraRows, availableBubbleHeight)
|
||||
: availableBubbleHeight
|
||||
const panelChromeHeight =
|
||||
isProviderSetup || isCapabilities ? 0 : isAppearance || isExport || flow === 'customize' ? 2 : 4
|
||||
const panelChromeHeight = isProviderSetup || isCapabilities ? 0 : isAppearance || flow === 'customize' ? 2 : 4
|
||||
const rowCapacity = isAppearance
|
||||
? settingsCategory === 'Appearance' && bubbleHeight < 18
|
||||
? 1
|
||||
@@ -608,9 +597,7 @@ function SetupWizardContent({
|
||||
)
|
||||
const directoryRows = new Map(
|
||||
rows.flatMap((row, index) =>
|
||||
canChooseDirectory() &&
|
||||
!row.disabled &&
|
||||
(row.field === 'memoryDir' || row.field === 'skills' || row.field === 'exportPath')
|
||||
canChooseDirectory() && !row.disabled && (row.field === 'memoryDir' || row.field === 'skills')
|
||||
? [[index, row.field] as const]
|
||||
: []
|
||||
)
|
||||
@@ -1049,7 +1036,7 @@ function SetupWizardContent({
|
||||
setFocusedAction(undefined)
|
||||
}
|
||||
|
||||
function browseForDirectory(field: 'importPath' | 'exportPath' | 'memoryDir' | 'skills'): void {
|
||||
function browseForDirectory(field: 'importPath' | 'memoryDir' | 'skills'): void {
|
||||
if (choosingDirectoryRef.current) {
|
||||
return
|
||||
}
|
||||
@@ -1060,22 +1047,15 @@ function SetupWizardContent({
|
||||
const selection =
|
||||
field === 'importPath'
|
||||
? chooseAgentProject()
|
||||
: field === 'exportPath'
|
||||
? chooseSaveFile('Export harness project', exportFileName(draft.profile.name, exportLanguage))
|
||||
: chooseDirectory(
|
||||
field === 'memoryDir'
|
||||
? 'Choose a directory for agent memory'
|
||||
: 'Choose a directory containing agent skills'
|
||||
)
|
||||
: chooseDirectory(
|
||||
field === 'memoryDir' ? 'Choose a directory for agent memory' : 'Choose a directory containing agent skills'
|
||||
)
|
||||
void selection
|
||||
.then((path) => {
|
||||
if (!path) return
|
||||
if (field === 'importPath') {
|
||||
setImportPath(path)
|
||||
setSelection(0)
|
||||
} else if (field === 'exportPath') {
|
||||
setExportPath(path)
|
||||
setExportedPath(undefined)
|
||||
} else {
|
||||
updateProfile(field === 'skills' ? { skills: [path] } : { memory: memoryForDir(path) })
|
||||
}
|
||||
@@ -1098,11 +1078,7 @@ function SetupWizardContent({
|
||||
setFlow(nextFlow)
|
||||
setStep(1)
|
||||
setError(undefined)
|
||||
if (nextFlow === 'export') {
|
||||
setSelection(0)
|
||||
setExportedPath(undefined)
|
||||
setExportPath(exportFileName(draft.profile.name, exportLanguage))
|
||||
} else if (nextFlow !== 'import') {
|
||||
if (nextFlow !== 'import') {
|
||||
const provider = providerFromModel(draft.profile.model) ?? 'bedrock'
|
||||
if (nextFlow === 'quickstart') {
|
||||
setDraft(quickstartDraft(provider, effectiveEnvironment))
|
||||
@@ -1116,6 +1092,18 @@ function SetupWizardContent({
|
||||
})
|
||||
}
|
||||
|
||||
function chooseOpeningChoice(choice: (typeof OPENING_CHOICES)[number]['id']): void {
|
||||
if (choice !== 'resume') {
|
||||
chooseFlow(choice)
|
||||
return
|
||||
}
|
||||
if (config.needsSetup()) {
|
||||
setError('No harness is ready to resume yet. Choose Quickstart or Customize to create one, or Import your own.')
|
||||
return
|
||||
}
|
||||
onCancel?.(0)
|
||||
}
|
||||
|
||||
function completeSetup(completedDraft: SetupDraft = draft, agentProject?: string): void {
|
||||
let saved: Promise<void>
|
||||
setSaving(true)
|
||||
@@ -1165,31 +1153,18 @@ function SetupWizardContent({
|
||||
})
|
||||
}
|
||||
|
||||
function runExport(): void {
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
void exportSavedAgent(config.snapshot(), exportLanguage, exportPath)
|
||||
.then(setExportedPath)
|
||||
.catch((cause: unknown) => setError(errorMessage(cause)))
|
||||
.finally(() => setSaving(false))
|
||||
}
|
||||
|
||||
function continueFlow(): void {
|
||||
if (editing || selecting || saving) {
|
||||
return
|
||||
}
|
||||
if (step === 0) {
|
||||
chooseFlow(OPENING_CHOICES[selection]!.id)
|
||||
chooseOpeningChoice(OPENING_CHOICES[selection]!.id)
|
||||
return
|
||||
}
|
||||
if (isAppearance) {
|
||||
completeSetup()
|
||||
return
|
||||
}
|
||||
if (flow === 'export') {
|
||||
runExport()
|
||||
return
|
||||
}
|
||||
if (flow === 'import') {
|
||||
try {
|
||||
const imported = importAgentProject(importPath)
|
||||
@@ -1329,9 +1304,6 @@ function SetupWizardContent({
|
||||
}
|
||||
if (editing.field === 'importPath') {
|
||||
setImportPath(value)
|
||||
} else if (editing.field === 'exportPath') {
|
||||
setExportPath(value)
|
||||
setExportedPath(undefined)
|
||||
} else if (editing.field === 'memoryDir') {
|
||||
updateProfile({ memory: memoryForDir(value) })
|
||||
} else if (editing.field === 'skills') {
|
||||
@@ -1369,7 +1341,7 @@ function SetupWizardContent({
|
||||
|
||||
function activateRow(index: number): void {
|
||||
if (step === 0) {
|
||||
chooseFlow(OPENING_CHOICES[index]!.id)
|
||||
chooseOpeningChoice(OPENING_CHOICES[index]!.id)
|
||||
return
|
||||
}
|
||||
if (isImport && index === 1) {
|
||||
@@ -1958,16 +1930,12 @@ function SetupWizardContent({
|
||||
(flow === 'customize' && step === CUSTOMIZE_STEPS.length) ||
|
||||
(flow === 'import' && step === 1)
|
||||
const primaryLabel = saving
|
||||
? isExport
|
||||
? 'Exporting...'
|
||||
: 'Saving...'
|
||||
: isExport
|
||||
? 'Export'
|
||||
: isSetupCompletion
|
||||
? deferred || !config.needsSetup()
|
||||
? 'Save and Launch'
|
||||
: 'Launch Strands harness'
|
||||
: 'Continue'
|
||||
? 'Saving...'
|
||||
: isSetupCompletion
|
||||
? deferred || !config.needsSetup()
|
||||
? 'Save and Launch'
|
||||
: 'Launch Strands harness'
|
||||
: 'Continue'
|
||||
const escapeAction = editing || selecting ? 'cancel' : step === 0 ? 'exit' : 'back'
|
||||
const navigationHints = editing
|
||||
? [
|
||||
@@ -2049,6 +2017,7 @@ function SetupWizardContent({
|
||||
topGap={openingTopGap}
|
||||
animate={appearance.animations}
|
||||
{...(error ? { error } : {})}
|
||||
{...(availableUpdate ? { availableUpdate } : {})}
|
||||
onRowElement={(index, element) => registerElement(rowElements.current, index, element)}
|
||||
/>
|
||||
</Fade>
|
||||
@@ -2077,16 +2046,10 @@ function SetupWizardContent({
|
||||
overflow="hidden"
|
||||
backgroundColor={PANEL_BACKGROUND}
|
||||
>
|
||||
{!isAppearance && (isExport || (flow === 'customize' && !isProviderSetup && !isCapabilities)) ? (
|
||||
{!isAppearance && flow === 'customize' && !isProviderSetup && !isCapabilities ? (
|
||||
<Box paddingX={1} marginBottom={1} justifyContent="space-between" flexShrink={0}>
|
||||
<Text bold color={accent}>
|
||||
{isSettings
|
||||
? 'Settings'
|
||||
: isAppearance
|
||||
? 'Appearance'
|
||||
: isExport
|
||||
? 'Export your agent'
|
||||
: CUSTOMIZE_STEPS[step - 1]}
|
||||
{isSettings ? 'Settings' : isAppearance ? 'Appearance' : CUSTOMIZE_STEPS[step - 1]}
|
||||
</Text>
|
||||
{customizeOverflowLabel ? <Text dimColor>{customizeOverflowLabel}</Text> : null}
|
||||
</Box>
|
||||
|
||||
@@ -40,6 +40,7 @@ export function OpeningMenu({
|
||||
topGap,
|
||||
animate,
|
||||
error,
|
||||
availableUpdate,
|
||||
onRowElement,
|
||||
}: {
|
||||
width: number
|
||||
@@ -51,6 +52,7 @@ export function OpeningMenu({
|
||||
topGap: number
|
||||
animate: boolean
|
||||
error?: string
|
||||
availableUpdate?: string
|
||||
onRowElement(index: number, element: DOMElement | null): void
|
||||
}): ReactElement {
|
||||
const palette = useTheme()
|
||||
@@ -112,7 +114,19 @@ export function OpeningMenu({
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
{error ? <Text color="red">{error}</Text> : null}
|
||||
{availableUpdate ? (
|
||||
<Box width={gridWidth} marginLeft={left} marginTop={1}>
|
||||
<Text wrap="truncate-end">
|
||||
<Text color={palette.accent}>Strands CLI {availableUpdate} is available.</Text>
|
||||
<Text color={palette.muted}> Run `strands update` to install it.</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{error ? (
|
||||
<Box width={gridWidth} marginLeft={left} marginTop={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
</FadeIn>
|
||||
)
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from './profile-fields.js'
|
||||
import { canChooseDirectory } from '../../terminal/directory-picker.js'
|
||||
import type { ChatSettings } from '../../chat/types.js'
|
||||
import type { AgentProjectLanguage } from '../../project/import.js'
|
||||
import { SETTING_DEFINITIONS, VISUAL_SETTING_DEFINITIONS } from '../../settings.js'
|
||||
import { PERMISSION_CHOICES, permissionToolDescription, permissionToolNames } from '../../permissions/settings.js'
|
||||
import {
|
||||
@@ -54,7 +53,7 @@ export const OPENING_CHOICES = [
|
||||
description: 'Customize your harness from scratch. Model, prompt, tools, and beyond.',
|
||||
},
|
||||
{ id: 'import', title: 'Import', description: 'Load in your custom harness from a file or zip.' },
|
||||
{ id: 'export', title: 'Export', description: 'Save your harness as a TypeScript or Python project.' },
|
||||
{ id: 'resume', title: 'Resume', description: 'Return to your harness and continue working.' },
|
||||
] as const
|
||||
const SETUP_STEP_INSTRUCTIONS = {
|
||||
quickstart: ['Pick a model for your agent'],
|
||||
@@ -68,14 +67,13 @@ const SETUP_STEP_INSTRUCTIONS = {
|
||||
'Review your agent',
|
||||
],
|
||||
import: ['Choose an agent to import'],
|
||||
export: ['Export your agent'],
|
||||
} as const satisfies Record<SetupFlow, readonly string[]>
|
||||
|
||||
export function setupStepProgress(
|
||||
flow: SetupFlow | undefined,
|
||||
step: number
|
||||
): { current: number; total: number; instruction: string; label?: string } | undefined {
|
||||
if (!flow || step === 0 || step === APPEARANCE_STEP || ((flow === 'import' || flow === 'export') && step === 1)) {
|
||||
if (!flow || step === 0 || step === APPEARANCE_STEP || (flow === 'import' && step === 1)) {
|
||||
return undefined
|
||||
}
|
||||
const instruction = SETUP_STEP_INSTRUCTIONS[flow][step - 1]!
|
||||
@@ -134,40 +132,6 @@ export function wizardSettingsRows(
|
||||
)
|
||||
}
|
||||
|
||||
export function exportRows(
|
||||
language: AgentProjectLanguage,
|
||||
path: string,
|
||||
exportedPath: string | undefined,
|
||||
setLanguage: (language: AgentProjectLanguage) => void,
|
||||
setEditing: (editing: { field: EditableField; value: string }) => void
|
||||
): WizardRow[] {
|
||||
return [
|
||||
{
|
||||
id: 'export-language',
|
||||
label: 'Language',
|
||||
description: '',
|
||||
choices: (['typescript', 'python'] as const).map((option) => ({
|
||||
label: option === 'typescript' ? 'TypeScript' : 'Python',
|
||||
value: option,
|
||||
active: option === language,
|
||||
activate: (): void => setLanguage(option),
|
||||
})),
|
||||
activate: noop,
|
||||
},
|
||||
{
|
||||
id: 'export-path',
|
||||
label: 'Save ZIP to',
|
||||
description: path,
|
||||
input: true,
|
||||
field: 'exportPath',
|
||||
activate: (): void => setEditing({ field: 'exportPath', value: path }),
|
||||
},
|
||||
...(exportedPath
|
||||
? [{ id: 'export-saved', label: 'Saved', description: exportedPath, status: 'success' as const, activate: noop }]
|
||||
: []),
|
||||
]
|
||||
}
|
||||
|
||||
export function rowsForStep(
|
||||
step: number,
|
||||
flow: SetupFlow | undefined,
|
||||
|
||||
@@ -3,9 +3,8 @@ import type { HarnessAgentConfig } from '@strands-agents/harness'
|
||||
import type { PermissionMode, ProviderEnvironmentKey, ProviderId } from '../../config.js'
|
||||
import type { ChatSettings } from '../../chat/types.js'
|
||||
|
||||
export type EditableField =
|
||||
'name' | 'instructions' | 'skills' | 'memoryDir' | 'importPath' | 'exportPath' | ProviderEnvironmentKey
|
||||
export type SetupFlow = 'quickstart' | 'customize' | 'import' | 'export'
|
||||
export type EditableField = 'name' | 'instructions' | 'skills' | 'memoryDir' | 'importPath' | ProviderEnvironmentKey
|
||||
export type SetupFlow = 'quickstart' | 'customize' | 'import'
|
||||
|
||||
export type AppearanceSettings = Pick<
|
||||
ChatSettings,
|
||||
|
||||
@@ -256,7 +256,7 @@ function NoticeView({ notice }: { notice: ChatNotice }): ReactElement {
|
||||
? theme.accent
|
||||
: 'green'
|
||||
return (
|
||||
<Box paddingLeft={2}>
|
||||
<Box paddingLeft={1}>
|
||||
<Text>
|
||||
<Text color={color}>{marker}</Text> {notice.text}
|
||||
{notice.taskId ? <Text dimColor> - {notice.taskId}</Text> : null}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { updateCli, type UpdateRunner } from '../src/cli/update.js'
|
||||
import { availableCliUpdate, compareVersions, isNewerVersion } from '../src/tui/update-check.js'
|
||||
|
||||
function memoryOutput(): { stream: Pick<NodeJS.WriteStream, 'write'>; text: () => string } {
|
||||
let value = ''
|
||||
return {
|
||||
stream: {
|
||||
write(chunk: string | Uint8Array) {
|
||||
value += chunk.toString()
|
||||
return true
|
||||
},
|
||||
},
|
||||
text: () => value,
|
||||
}
|
||||
}
|
||||
|
||||
function runner(latest = '"1.2.0"\n', exitCode = 0): UpdateRunner {
|
||||
return {
|
||||
capture: vi.fn(async () => latest),
|
||||
inherit: vi.fn(async () => exitCode),
|
||||
}
|
||||
}
|
||||
|
||||
describe('updateCli', () => {
|
||||
it('does not update a development checkout', async () => {
|
||||
const commands = runner()
|
||||
const errors = memoryOutput()
|
||||
|
||||
expect(await updateCli({ currentVersion: '0.0.1-development', runner: commands, errorOutput: errors.stream })).toBe(
|
||||
1
|
||||
)
|
||||
expect(commands.capture).not.toHaveBeenCalled()
|
||||
expect(errors.text()).toContain('development checkout')
|
||||
})
|
||||
|
||||
it('reports an already-current global installation', async () => {
|
||||
const commands = runner()
|
||||
const output = memoryOutput()
|
||||
|
||||
expect(await updateCli({ currentVersion: '1.2.0', runner: commands, output: output.stream })).toBe(0)
|
||||
expect(commands.capture).toHaveBeenCalledWith('npm', ['view', '@strands-agents/cli@latest', 'version', '--json'])
|
||||
expect(commands.inherit).not.toHaveBeenCalled()
|
||||
expect(output.text()).toBe('Strands CLI 1.2.0 is already up to date.\n')
|
||||
})
|
||||
|
||||
it('installs the latest global package', async () => {
|
||||
const commands = runner()
|
||||
const output = memoryOutput()
|
||||
|
||||
expect(await updateCli({ currentVersion: '1.1.0', runner: commands, output: output.stream })).toBe(0)
|
||||
expect(commands.inherit).toHaveBeenCalledWith('npm', ['install', '--global', '@strands-agents/cli@1.2.0'])
|
||||
expect(output.text()).toContain('Updating Strands CLI from 1.1.0 to 1.2.0')
|
||||
expect(output.text()).toContain('Updated Strands CLI to 1.2.0')
|
||||
})
|
||||
|
||||
it('uses npm.cmd on Windows and preserves an install failure', async () => {
|
||||
const commands = runner('"1.2.0"', 7)
|
||||
const output = memoryOutput()
|
||||
const errors = memoryOutput()
|
||||
|
||||
expect(
|
||||
await updateCli({
|
||||
currentVersion: '1.1.0',
|
||||
runner: commands,
|
||||
output: output.stream,
|
||||
errorOutput: errors.stream,
|
||||
platform: 'win32',
|
||||
})
|
||||
).toBe(7)
|
||||
expect(commands.capture).toHaveBeenCalledWith('cmd.exe', [
|
||||
'/d',
|
||||
'/s',
|
||||
'/c',
|
||||
'npm.cmd view @strands-agents/cli@latest version --json',
|
||||
])
|
||||
expect(commands.inherit).toHaveBeenCalledWith('cmd.exe', [
|
||||
'/d',
|
||||
'/s',
|
||||
'/c',
|
||||
'npm.cmd install --global @strands-agents/cli@1.2.0',
|
||||
])
|
||||
expect(errors.text()).toContain('exit code 7')
|
||||
})
|
||||
|
||||
it('does not downgrade a version newer than npm latest', async () => {
|
||||
const commands = runner('"1.2.0"')
|
||||
const output = memoryOutput()
|
||||
|
||||
expect(await updateCli({ currentVersion: '2.0.0-beta.1', runner: commands, output: output.stream })).toBe(0)
|
||||
expect(commands.inherit).not.toHaveBeenCalled()
|
||||
expect(output.text()).toContain('newer than npm latest 1.2.0')
|
||||
})
|
||||
|
||||
it('treats build metadata variants as the same version', async () => {
|
||||
const commands = runner('"1.2.0"')
|
||||
const output = memoryOutput()
|
||||
|
||||
expect(await updateCli({ currentVersion: '1.2.0+local', runner: commands, output: output.stream })).toBe(0)
|
||||
expect(commands.inherit).not.toHaveBeenCalled()
|
||||
expect(output.text()).toContain('already up to date')
|
||||
})
|
||||
|
||||
it('reports invalid npm registry output', async () => {
|
||||
const errors = memoryOutput()
|
||||
|
||||
expect(
|
||||
await updateCli({
|
||||
currentVersion: '1.1.0',
|
||||
runner: runner('{}'),
|
||||
errorOutput: errors.stream,
|
||||
})
|
||||
).toBe(1)
|
||||
expect(errors.text()).toContain('npm returned an invalid package version')
|
||||
})
|
||||
})
|
||||
|
||||
describe('availableCliUpdate', () => {
|
||||
it('reports a newer published version', async () => {
|
||||
const resolveLatest = vi.fn(async () => '1.2.0')
|
||||
|
||||
expect(await availableCliUpdate({ currentVersion: '1.1.0', resolveLatest })).toBe('1.2.0')
|
||||
expect(resolveLatest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stays quiet when current, newer, offline, or running from a development checkout', async () => {
|
||||
const offline = vi.fn(async () => {
|
||||
throw new Error('offline')
|
||||
})
|
||||
const current = vi.fn(async () => '1.2.0')
|
||||
|
||||
expect(await availableCliUpdate({ currentVersion: '1.2.0', resolveLatest: current })).toBeUndefined()
|
||||
expect(await availableCliUpdate({ currentVersion: '2.0.0-beta.1', resolveLatest: current })).toBeUndefined()
|
||||
expect(await availableCliUpdate({ currentVersion: '1.1.0', resolveLatest: offline })).toBeUndefined()
|
||||
expect(await availableCliUpdate({ currentVersion: '0.0.1-development', resolveLatest: current })).toBeUndefined()
|
||||
expect(current).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNewerVersion', () => {
|
||||
it.each([
|
||||
['1.2.0', '1.1.9', true],
|
||||
['2.0.0', '1.99.99', true],
|
||||
['1.2.0', '1.2.0-beta.2', true],
|
||||
['1.2.0-beta.10', '1.2.0-beta.2', true],
|
||||
['1.2.0-beta.2', '1.2.0', false],
|
||||
['1.2.0', '2.0.0-beta.1', false],
|
||||
['invalid', '1.2.0', false],
|
||||
])('compares %s against %s', (candidate, current, expected) => {
|
||||
expect(isNewerVersion(candidate, current)).toBe(expected)
|
||||
})
|
||||
|
||||
it('ignores build metadata and rejects malformed versions', () => {
|
||||
expect(compareVersions('1.2.0+published', '1.2.0+local')).toBe(0)
|
||||
expect(compareVersions('invalid', '1.2.0')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -241,6 +241,12 @@ describe('parseArgs', () => {
|
||||
expect(parseArgs(['--setup']).setup).toBe(true)
|
||||
})
|
||||
|
||||
it('captures the update command without treating other requests as commands', () => {
|
||||
expect(parseArgs(['update']).update).toBe(true)
|
||||
expect(parseArgs(['summarize']).request).toBe('summarize')
|
||||
expect(() => parseArgs(['update', 'unexpected'])).toThrow(/too many arguments/u)
|
||||
})
|
||||
|
||||
it('collects repeatable MCP configuration paths', () => {
|
||||
const args = parseArgs(['--mcp-config', '/tmp/mcp-a.json', '--mcp-config', '/tmp/mcp-b.json'])
|
||||
expect(args.mcpConfig).toEqual(['/tmp/mcp-a.json', '/tmp/mcp-b.json'])
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ChatController,
|
||||
type ChatBackend,
|
||||
type ChatContextUsage,
|
||||
type ChatEvent,
|
||||
type ChatPermissionRequest,
|
||||
type ChatRunResult,
|
||||
@@ -1897,12 +1898,12 @@ describe('ChatController', () => {
|
||||
expect(requestSetup).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('compacts context and clears both the conversation and visible transcript', async () => {
|
||||
it('compacts context, keeps the visible transcript, and shows the measured context', async () => {
|
||||
const target = backend(async function* (prompt) {
|
||||
yield { type: 'textDelta', text: `reply to ${prompt}` }
|
||||
return { stopReason: 'endTurn', context: { currentTokens: 120 } }
|
||||
})
|
||||
let compacted = false
|
||||
let compacted: ChatContextUsage | undefined
|
||||
target.compact = vi.fn(async () => compacted)
|
||||
target.clear = vi.fn(async () => {})
|
||||
const controller = new ChatController(target, { runtime: { session: 'saved: active' } })
|
||||
@@ -1914,15 +1915,18 @@ describe('ChatController', () => {
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
completedTurns: [{ prompt: 'Remember this' }],
|
||||
context: { currentTokens: 120 },
|
||||
notices: [{ status: 'delivered', text: 'Nothing to compact yet' }],
|
||||
})
|
||||
|
||||
compacted = true
|
||||
compacted = { currentTokens: 40, contextWindow: 1_000 }
|
||||
await controller.submit('/compact')
|
||||
|
||||
expect(target.compact).toHaveBeenCalledTimes(2)
|
||||
expect(controller.getSnapshot()).toMatchObject({
|
||||
completedTurns: [{ prompt: 'Remember this' }],
|
||||
context: {},
|
||||
expect(controller.getSnapshot().completedTurns).toMatchObject([{ prompt: 'Remember this' }])
|
||||
expect(controller.getSnapshot().context).toEqual({ currentTokens: 40, contextWindow: 1_000 })
|
||||
expect(controller.getSnapshot().notices.at(-1)).toMatchObject({
|
||||
status: 'success',
|
||||
text: 'Compacted older conversation context into a summary',
|
||||
})
|
||||
expect(controller.getSnapshot().composerStatus).toBeUndefined()
|
||||
expect(controller.getSnapshot().panel).toBeUndefined()
|
||||
@@ -1947,7 +1951,7 @@ describe('ChatController', () => {
|
||||
const target = backend()
|
||||
target.compact = vi.fn(async () => {
|
||||
await gate
|
||||
return true
|
||||
return {}
|
||||
})
|
||||
const controller = new ChatController(target)
|
||||
|
||||
@@ -1999,6 +2003,32 @@ describe('ChatController', () => {
|
||||
expect(prompts).toEqual(['this change', 'that change'])
|
||||
})
|
||||
|
||||
it('runs a skill chosen from the skills panel and keeps its details reachable', async () => {
|
||||
const prompts: string[] = []
|
||||
const review = { name: 'review', description: 'Review code', instructions: 'Inspect carefully.', active: false }
|
||||
const activate = vi.fn(async () => ({ ...review, active: true }))
|
||||
const controller = new ChatController(
|
||||
backend(async function* (prompt) {
|
||||
prompts.push(prompt)
|
||||
yield { type: 'textDelta', text: 'done' }
|
||||
return { stopReason: 'endTurn' }
|
||||
}),
|
||||
{ skills: { list: async () => [review], activate } }
|
||||
)
|
||||
|
||||
await controller.submit('/skills')
|
||||
expect(controller.openSkillDetail('review')).toBe(true)
|
||||
expect(controller.getSnapshot().panel).toMatchObject({ kind: 'detail', body: 'Inspect carefully.' })
|
||||
controller.dismissPanel()
|
||||
|
||||
const row = controller.getSnapshot().panel!.rows.find((candidate) => candidate.value === 'review')!
|
||||
await controller.activatePanelRow(row)
|
||||
|
||||
expect(activate).toHaveBeenCalledWith('review')
|
||||
expect(prompts).toEqual(['Use the review skill.'])
|
||||
expect(controller.getSnapshot().panel).toBeUndefined()
|
||||
})
|
||||
|
||||
it('represents an empty MCP configuration as zero servers with its checked path', async () => {
|
||||
const list = vi.fn(async () => [])
|
||||
const controller = new ChatController(backend(), {
|
||||
|
||||
@@ -381,6 +381,26 @@ describe('ConversationManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('opens a rename panel when no name is provided', async () => {
|
||||
const primary = new ChatController(backend('primary', emptyRun))
|
||||
const manager = new ConversationManager(primary, { fork: async () => primary })
|
||||
|
||||
try {
|
||||
await manager.submit('/rename')
|
||||
expect(manager.getSnapshot().panel).toMatchObject({
|
||||
kind: 'rename',
|
||||
title: 'Rename agent',
|
||||
rows: [{ label: 'Current name', description: 'Strands harness' }],
|
||||
})
|
||||
|
||||
await manager.activatePanelRow({ label: 'Rename', description: '', value: 'rename:Lead Reviewer' })
|
||||
await manager.submit('/agents')
|
||||
expect(manager.getSnapshot().panel?.rows[0]?.label).toBe('Lead Reviewer')
|
||||
} finally {
|
||||
await manager.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('retries failed session lookup and startup before opening a separate workspace conversation', async () => {
|
||||
const reference = 'strands-session:remote'
|
||||
const target = {
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('help', () => {
|
||||
})
|
||||
|
||||
it('uses reported capabilities for ACP connections', async () => {
|
||||
const compact = vi.fn(async () => true)
|
||||
const compact = vi.fn(async () => ({}))
|
||||
const controller = new ChatController(backend({ protocol: 'acp', compact }))
|
||||
await controller.submit('/help')
|
||||
expect(row(controller, '/compact').value).toBe('help:command:compact')
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { captureNpm, npmInvocation } from '../src/tui/npm.js'
|
||||
|
||||
const execFile = vi.hoisted(() =>
|
||||
vi.fn((_command: string, _args: string[], _options: object, callback: (error: null, result: object) => void) => {
|
||||
callback(null, { stdout: '"1.2.0"\n', stderr: '' })
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('node:child_process', async (original) => ({
|
||||
...(await original<typeof import('node:child_process')>()),
|
||||
execFile,
|
||||
}))
|
||||
|
||||
describe('npmInvocation', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('runs npm directly outside Windows', () => {
|
||||
expect(npmInvocation(['view', '@strands-agents/cli@latest'], { platform: 'darwin' })).toEqual({
|
||||
command: 'npm',
|
||||
args: ['view', '@strands-agents/cli@latest'],
|
||||
})
|
||||
})
|
||||
|
||||
it('runs npm.cmd through the Windows command shell', () => {
|
||||
vi.stubEnv('ComSpec', 'C:\\Windows\\System32\\cmd.exe')
|
||||
|
||||
expect(npmInvocation(['install', '--global', '@strands-agents/cli@1.2.0'], { platform: 'win32' })).toEqual({
|
||||
command: 'C:\\Windows\\System32\\cmd.exe',
|
||||
args: ['/d', '/s', '/c', 'npm.cmd install --global @strands-agents/cli@1.2.0'],
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to cmd.exe when ComSpec is unset', () => {
|
||||
vi.stubEnv('ComSpec', undefined)
|
||||
|
||||
expect(npmInvocation(['view'], { platform: 'win32' }).command).toBe('cmd.exe')
|
||||
})
|
||||
|
||||
it.each(['1.2.0 & calc', '1.2.0|more', '"1.2.0"', '%PATH%', '1.2.0^', 'a b'])(
|
||||
'rejects %j before it reaches cmd.exe',
|
||||
(argument) => {
|
||||
expect(() => npmInvocation(['install', argument], { platform: 'win32' })).toThrow(/unsafe for cmd.exe/u)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('captureNpm', () => {
|
||||
it('passes the Windows invocation, timeout, and hidden console to execFile', async () => {
|
||||
expect(
|
||||
await captureNpm(['view', '@strands-agents/cli@latest', 'version', '--json'], {
|
||||
platform: 'win32',
|
||||
commandShell: 'cmd.exe',
|
||||
timeout: 3_000,
|
||||
})
|
||||
).toBe('"1.2.0"\n')
|
||||
expect(execFile).toHaveBeenCalledWith(
|
||||
'cmd.exe',
|
||||
['/d', '/s', '/c', 'npm.cmd view @strands-agents/cli@latest version --json'],
|
||||
{ encoding: 'utf8', timeout: 3_000, windowsHide: true },
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createElement } from 'react'
|
||||
import { render } from 'ink'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ChatController, type ChatBackend } from '../src/tui/chat/controller.js'
|
||||
import { ConversationManager } from '../src/tui/session/conversations.js'
|
||||
import { sanitizeTerminalText } from '../src/tui/terminal/sanitize.js'
|
||||
import { ChatApp } from '../src/tui/view/app.js'
|
||||
import { ttyInput, ttyOutput } from './fixtures/terminal.js'
|
||||
|
||||
describe('rename panel', () => {
|
||||
it('renames the active agent from an inline text field', async () => {
|
||||
const input = ttyInput()
|
||||
const output = ttyOutput(80, 24)
|
||||
let frame = ''
|
||||
output.on('data', (chunk: Buffer) => {
|
||||
if (chunk.toString().includes('\n')) {
|
||||
frame = sanitizeTerminalText(chunk.toString())
|
||||
}
|
||||
})
|
||||
const primary = new ChatController(backend())
|
||||
const manager = new ConversationManager(primary, { fork: async () => primary })
|
||||
const instance = render(createElement(ChatApp, { controller: manager }), {
|
||||
stdin: input,
|
||||
stdout: output,
|
||||
stderr: output,
|
||||
interactive: true,
|
||||
debug: true,
|
||||
incrementalRendering: false,
|
||||
patchConsole: false,
|
||||
exitOnCtrlC: false,
|
||||
})
|
||||
|
||||
try {
|
||||
await manager.submit('/rename')
|
||||
await instance.waitUntilRenderFlush()
|
||||
expect(frame).toContain('Rename agent')
|
||||
expect(frame).toContain('Current name: Strands harness')
|
||||
expect(frame).toContain('New name')
|
||||
|
||||
input.write('Lead Reviewer')
|
||||
await instance.waitUntilRenderFlush()
|
||||
expect(frame).toContain('Lead Reviewer')
|
||||
|
||||
input.write('\r')
|
||||
await vi.waitFor(() => expect(manager.getSnapshot().panel).toBeUndefined())
|
||||
await manager.submit('/agents')
|
||||
expect(manager.getSnapshot().panel?.rows[0]?.label).toBe('Lead Reviewer')
|
||||
} finally {
|
||||
instance.unmount()
|
||||
await instance.waitUntilExit()
|
||||
await manager.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('cancels without renaming when Escape is pressed', async () => {
|
||||
const input = ttyInput()
|
||||
const output = ttyOutput(80, 24)
|
||||
const primary = new ChatController(backend())
|
||||
const manager = new ConversationManager(primary, { fork: async () => primary })
|
||||
const instance = render(createElement(ChatApp, { controller: manager }), {
|
||||
stdin: input,
|
||||
stdout: output,
|
||||
stderr: output,
|
||||
interactive: true,
|
||||
patchConsole: false,
|
||||
exitOnCtrlC: false,
|
||||
})
|
||||
|
||||
try {
|
||||
await manager.submit('/rename')
|
||||
await instance.waitUntilRenderFlush()
|
||||
input.write('Temporary')
|
||||
input.write('\u001b')
|
||||
await vi.waitFor(() => expect(manager.getSnapshot().panel).toBeUndefined())
|
||||
|
||||
await manager.submit('/agents')
|
||||
expect(manager.getSnapshot().panel?.rows[0]?.label).toBe('Strands harness')
|
||||
} finally {
|
||||
instance.unmount()
|
||||
await instance.waitUntilExit()
|
||||
await manager.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function backend(): ChatBackend {
|
||||
return {
|
||||
id: 'primary',
|
||||
name: 'Strands harness',
|
||||
protocol: 'strands',
|
||||
stream: async function* () {
|
||||
yield { type: 'textDelta', text: '' }
|
||||
return { stopReason: 'endTurn' }
|
||||
},
|
||||
cancel: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
@@ -604,9 +603,7 @@ describe('setup presentation', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('exports the saved agent from the Export card', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'strands-setup-export-'))
|
||||
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(root)
|
||||
it('announces a newer CLI release on the opening menu', async () => {
|
||||
const input = ttyInput()
|
||||
const output = ttyOutput(120, 30)
|
||||
let frame = ''
|
||||
@@ -615,21 +612,106 @@ describe('setup presentation', () => {
|
||||
frame = sanitizeTerminalText(chunk.toString())
|
||||
}
|
||||
})
|
||||
const config = CliConfigStore.memory(
|
||||
{},
|
||||
{ animations: false },
|
||||
{ profile: { name: 'Export fixture', skills: false, memory: false, session: false } }
|
||||
const instance = render(
|
||||
createElement(SetupWizard, {
|
||||
config: CliConfigStore.memory({}, { animations: false }),
|
||||
onComplete: () => {},
|
||||
checkForUpdate: async () => '9.9.9',
|
||||
}),
|
||||
{
|
||||
stdin: input,
|
||||
stdout: output,
|
||||
stderr: output,
|
||||
interactive: true,
|
||||
debug: true,
|
||||
incrementalRendering: false,
|
||||
patchConsole: false,
|
||||
exitOnCtrlC: false,
|
||||
}
|
||||
)
|
||||
const instance = render(createElement(SetupWizard, { config, onComplete: () => {} }), {
|
||||
stdin: input,
|
||||
stdout: output,
|
||||
stderr: output,
|
||||
interactive: true,
|
||||
debug: true,
|
||||
incrementalRendering: false,
|
||||
patchConsole: false,
|
||||
exitOnCtrlC: false,
|
||||
try {
|
||||
await vi.waitFor(() =>
|
||||
expect(frame).toContain('Strands CLI 9.9.9 is available. Run `strands update` to install it.')
|
||||
)
|
||||
} finally {
|
||||
instance.unmount()
|
||||
await instance.waitUntilExit()
|
||||
}
|
||||
})
|
||||
|
||||
it('resumes a configured harness from the Resume card', async () => {
|
||||
const input = ttyInput()
|
||||
const output = ttyOutput(120, 30)
|
||||
let frame = ''
|
||||
output.on('data', (chunk: Buffer) => {
|
||||
if (chunk.toString().includes('\n')) {
|
||||
frame = sanitizeTerminalText(chunk.toString())
|
||||
}
|
||||
})
|
||||
const onCancel = vi.fn()
|
||||
const instance = render(
|
||||
createElement(SetupWizard, {
|
||||
config: CliConfigStore.memory({}, { animations: false }),
|
||||
onComplete: () => {},
|
||||
onCancel,
|
||||
}),
|
||||
{
|
||||
stdin: input,
|
||||
stdout: output,
|
||||
stderr: output,
|
||||
interactive: true,
|
||||
debug: true,
|
||||
incrementalRendering: false,
|
||||
patchConsole: false,
|
||||
exitOnCtrlC: false,
|
||||
}
|
||||
)
|
||||
const press = async (key: string): Promise<void> => {
|
||||
input.push(key)
|
||||
await instance.waitUntilRenderFlush()
|
||||
}
|
||||
try {
|
||||
await instance.waitUntilRenderFlush()
|
||||
expect(frame).toContain('Resume')
|
||||
expect(frame).not.toContain('Export')
|
||||
await press('\u001b[B')
|
||||
await press('\u001b[C')
|
||||
await press('\r')
|
||||
|
||||
expect(onCancel).toHaveBeenCalledWith(0)
|
||||
} finally {
|
||||
instance.unmount()
|
||||
await instance.waitUntilExit()
|
||||
}
|
||||
})
|
||||
|
||||
it('explains how to create a harness when Resume has nothing to open', async () => {
|
||||
const input = ttyInput()
|
||||
const output = ttyOutput(120, 30)
|
||||
let frame = ''
|
||||
output.on('data', (chunk: Buffer) => {
|
||||
if (chunk.toString().includes('\n')) {
|
||||
frame = sanitizeTerminalText(chunk.toString())
|
||||
}
|
||||
})
|
||||
const onCancel = vi.fn()
|
||||
const instance = render(
|
||||
createElement(SetupWizard, {
|
||||
config: CliConfigStore.memory({}, { animations: false }, { onboardingVersion: 0 }),
|
||||
onComplete: () => {},
|
||||
onCancel,
|
||||
}),
|
||||
{
|
||||
stdin: input,
|
||||
stdout: output,
|
||||
stderr: output,
|
||||
interactive: true,
|
||||
debug: true,
|
||||
incrementalRendering: false,
|
||||
patchConsole: false,
|
||||
exitOnCtrlC: false,
|
||||
}
|
||||
)
|
||||
const press = async (key: string): Promise<void> => {
|
||||
input.push(key)
|
||||
await instance.waitUntilRenderFlush()
|
||||
@@ -639,22 +721,18 @@ describe('setup presentation', () => {
|
||||
await press('\u001b[B')
|
||||
await press('\u001b[C')
|
||||
await press('\r')
|
||||
await vi.waitFor(() => expect(frame).toContain('Export your agent'))
|
||||
expect(frame).toContain('export-fixture-typescript.zip')
|
||||
|
||||
await press('\u001b[C')
|
||||
await vi.waitFor(() => expect(frame).toContain('export-fixture-python.zip'))
|
||||
await press('\u001b[Z')
|
||||
await press('\r')
|
||||
|
||||
const archive = join(root, 'export-fixture-python.zip')
|
||||
await vi.waitFor(() => expect(existsSync(archive)).toBe(true))
|
||||
await vi.waitFor(() => expect(frame).toContain('Saved'))
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
expect(frame).toContain('No harness is ready to resume yet.')
|
||||
expect(frame).toContain('Choose Quickstart or Customize')
|
||||
expect(frame).toContain('or Import')
|
||||
expect(frame).toContain('your own.')
|
||||
const lines = frame.split('\n')
|
||||
const errorLine = lines.findIndex((line) => line.includes('No harness is ready to resume yet.'))
|
||||
expect(lines[errorLine - 1]?.trim()).toBe('')
|
||||
} finally {
|
||||
instance.unmount()
|
||||
await instance.waitUntilExit()
|
||||
cwd.mockRestore()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -632,14 +632,16 @@ describe('StrandsChatBackend', () => {
|
||||
})
|
||||
const messages = [user]
|
||||
const saveSnapshot = vi.fn(async () => {})
|
||||
let summaryContent = [new TextBlock('summary')]
|
||||
const model = {
|
||||
modelId: 'anthropic.claude-test',
|
||||
getConfig: () => ({ modelId: 'anthropic.claude-test', contextWindowLimit: 1_000 }),
|
||||
countTokens: vi.fn(async () => 42),
|
||||
async *streamAggregated() {
|
||||
yield* []
|
||||
return {
|
||||
message: {
|
||||
content: [new TextBlock('summary')],
|
||||
content: summaryContent,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -733,7 +735,7 @@ describe('StrandsChatBackend', () => {
|
||||
new StrandsChatBackend(new AgentModelRuntime(differentModelAgent), { contextScope: '/workspace' }).contextUsage()
|
||||
).toBeUndefined()
|
||||
|
||||
expect(await backend.compact()).toBe(false)
|
||||
expect(await backend.compact()).toBeUndefined()
|
||||
expect(backend.contextUsage()).toEqual(expected)
|
||||
|
||||
messages.unshift(
|
||||
@@ -742,10 +744,35 @@ describe('StrandsChatBackend', () => {
|
||||
content: [new TextBlock('older context')],
|
||||
})
|
||||
)
|
||||
expect(await backend.compact()).toBe(true)
|
||||
expect(backend.contextUsage()).toBeUndefined()
|
||||
expect(assistant.metadata?.custom?.['strands.context.v1']).toBeUndefined()
|
||||
model.countTokens.mockResolvedValueOnce(1_500).mockResolvedValueOnce(1_400)
|
||||
const compacted = { currentTokens: 25, contextWindow: limit ?? 1_000 }
|
||||
expect(await backend.compact()).toEqual(compacted)
|
||||
expect(model.countTokens).toHaveBeenLastCalledWith(messages, { systemPrompt: 'system', toolSpecs: [] })
|
||||
expect(backend.contextUsage()).toEqual(compacted)
|
||||
expect(saveSnapshot).toHaveBeenCalledTimes(2)
|
||||
|
||||
model.countTokens.mockRejectedValueOnce(new Error('count failed')).mockRejectedValueOnce(new Error('count failed'))
|
||||
messages.unshift(
|
||||
new Message({
|
||||
role: 'user',
|
||||
content: [new TextBlock('more older context')],
|
||||
})
|
||||
)
|
||||
expect(await backend.compact()).toEqual({ contextWindow: limit ?? 1_000 })
|
||||
expect(backend.contextUsage()).toEqual({ contextWindow: limit ?? 1_000 })
|
||||
|
||||
messages.unshift(
|
||||
new Message({
|
||||
role: 'user',
|
||||
content: [new TextBlock('context without a reported size')],
|
||||
})
|
||||
)
|
||||
expect(await backend.compact()).toEqual({ currentTokens: 42, contextWindow: limit ?? 1_000 })
|
||||
|
||||
summaryContent = []
|
||||
const beforeFailure = [...messages]
|
||||
await expect(backend.compact()).rejects.toThrow('The model did not return a usable summary. Try /compact again.')
|
||||
expect(messages).toEqual(beforeFailure)
|
||||
})
|
||||
|
||||
it('includes provider-separated prompt-cache tokens in the latest context without using stale SDK totals', async () => {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createElement } from 'react'
|
||||
import { render } from 'ink'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { CliConfigStore } from '../src/tui/config.js'
|
||||
import { captureNpm } from '../src/tui/npm.js'
|
||||
import { sanitizeTerminalText } from '../src/tui/terminal/sanitize.js'
|
||||
import { SetupWizard } from '../src/tui/view/setup-wizard/index.js'
|
||||
import { ttyInput, ttyOutput } from './fixtures/terminal.js'
|
||||
|
||||
const NOTICE = 'Strands CLI 0.1.3 is available. Run `strands update` to install it.'
|
||||
|
||||
vi.mock('../src/tui/package-version.js', async (original) => ({
|
||||
...(await original<typeof import('../src/tui/package-version.js')>()),
|
||||
readCliVersion: () => '0.1.0',
|
||||
}))
|
||||
|
||||
vi.mock('../src/tui/npm.js', async (original) => ({
|
||||
...(await original<typeof import('../src/tui/npm.js')>()),
|
||||
captureNpm: vi.fn(async () => '"0.1.3"\n'),
|
||||
}))
|
||||
|
||||
vi.mock('../src/tui/provider/discovery.js', async (original) => ({
|
||||
...(await original<typeof import('../src/tui/provider/discovery.js')>()),
|
||||
discoverAwsConfiguration: vi.fn(() => ({ profiles: [], regions: [] })),
|
||||
discoverAwsCredentials: vi.fn(async () => 'valid'),
|
||||
discoverOllama: async () => ({ installed: false, running: false, models: [] }),
|
||||
discoverLiteLlm: vi.fn(async () => ({ reachable: false, authenticationRequired: false, models: [] })),
|
||||
discoverProviderModels: vi.fn(async () => ({ available: true, models: [] })),
|
||||
}))
|
||||
|
||||
async function renderSetup(props: { appearanceOnly?: boolean } = {}): Promise<{
|
||||
frame: () => string
|
||||
settle: () => Promise<void>
|
||||
close: () => Promise<void>
|
||||
}> {
|
||||
const output = ttyOutput(120, 30)
|
||||
let frame = ''
|
||||
output.on('data', (chunk: Buffer) => {
|
||||
if (chunk.toString().includes('\n')) {
|
||||
frame = sanitizeTerminalText(chunk.toString())
|
||||
}
|
||||
})
|
||||
const instance = render(
|
||||
createElement(SetupWizard, {
|
||||
config: CliConfigStore.memory({}, { animations: false }),
|
||||
onComplete: () => {},
|
||||
...props,
|
||||
}),
|
||||
{
|
||||
stdin: ttyInput(),
|
||||
stdout: output,
|
||||
stderr: output,
|
||||
interactive: true,
|
||||
debug: true,
|
||||
incrementalRendering: false,
|
||||
patchConsole: false,
|
||||
exitOnCtrlC: false,
|
||||
}
|
||||
)
|
||||
await instance.waitUntilRenderFlush()
|
||||
return {
|
||||
frame: () => frame,
|
||||
settle: async () => {
|
||||
await vi.waitFor(() => expect(captureNpm).toHaveBeenCalled())
|
||||
await instance.waitUntilRenderFlush()
|
||||
},
|
||||
close: async () => {
|
||||
instance.unmount()
|
||||
await instance.waitUntilExit()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('setup update notice', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(captureNpm).mockClear()
|
||||
vi.mocked(captureNpm).mockImplementation(async () => '"0.1.3"\n')
|
||||
})
|
||||
|
||||
it('asks npm for the latest release and announces it on the opening menu', async () => {
|
||||
const setup = await renderSetup()
|
||||
try {
|
||||
await vi.waitFor(() => expect(setup.frame()).toContain(NOTICE))
|
||||
expect(captureNpm).toHaveBeenCalledWith(['view', '@strands-agents/cli@latest', 'version', '--json'], {
|
||||
timeout: 3_000,
|
||||
})
|
||||
expect(setup.frame()).toContain('Resume')
|
||||
} finally {
|
||||
await setup.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['npm latest matches the running version', async (): Promise<string> => '"0.1.0"\n'],
|
||||
['npm latest is older than the running version', async (): Promise<string> => '"0.0.9"\n'],
|
||||
[
|
||||
'npm is unreachable',
|
||||
async (): Promise<string> => {
|
||||
throw new Error('ETIMEDOUT')
|
||||
},
|
||||
],
|
||||
['npm returns malformed output', async (): Promise<string> => 'not json'],
|
||||
])('shows no notice when %s', async (_case, resolve) => {
|
||||
vi.mocked(captureNpm).mockImplementation(resolve)
|
||||
const setup = await renderSetup()
|
||||
try {
|
||||
await setup.settle()
|
||||
expect(setup.frame()).toContain('Resume')
|
||||
expect(setup.frame()).not.toContain('is available')
|
||||
} finally {
|
||||
await setup.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips the check when setup only opens appearance settings', async () => {
|
||||
const setup = await renderSetup({ appearanceOnly: true })
|
||||
try {
|
||||
expect(captureNpm).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await setup.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,17 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('ChatView', () => {
|
||||
it('shows notices before the first turn below the startup banner', () => {
|
||||
const output = renderView({
|
||||
snapshot: snapshot({ notices: [{ id: 'notice-1', status: 'delivered', text: 'Nothing to compact yet' }] }),
|
||||
terminalWidth: 80,
|
||||
terminalHeight: 24,
|
||||
})
|
||||
|
||||
expect(output).toContain('╚══════╝')
|
||||
expect(output).toContain('Nothing to compact yet')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[80, 40],
|
||||
[40, 24],
|
||||
|
||||
Reference in New Issue
Block a user