feat(cli): add strands update and polish compact, skills, rename, and setup flows (#4612)

This commit is contained in:
notowen333
2026-09-25 16:36:17 -04:00
committed by GitHub
parent e2262ede4c
commit 923a5ac2e4
38 changed files with 1321 additions and 268 deletions
+8 -2
View File
@@ -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.”
+9 -1
View File
@@ -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"
+2
View File
@@ -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",
+43
View File
@@ -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>
+6
View File
@@ -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)
+90
View File
@@ -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)
}
})
})
},
}
+1 -1
View File
@@ -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' },
+9 -4
View File
@@ -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
+4 -1
View File
@@ -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
}
+49
View File
@@ -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(' ')
}
+10 -3
View File
@@ -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> {
+56 -7
View File
@@ -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:
+27 -12
View File
@@ -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 {
+20 -1
View File
@@ -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
+56 -10
View File
@@ -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)) {
+47
View File
@@ -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
}
+28
View File
@@ -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) {
+2 -1
View File
@@ -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'
+19 -15
View File
@@ -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">
+23 -9
View File
@@ -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)} />
}
+50
View File
@@ -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>
)
}
+75 -112
View File
@@ -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>
)
+2 -38
View File
@@ -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,
+1 -1
View File
@@ -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}
+158
View File
@@ -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()
})
})
+6
View File
@@ -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'])
+37 -7
View File
@@ -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 = {
+1 -1
View File
@@ -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')
+67
View File
@@ -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)
)
})
})
+99
View File
@@ -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(),
}
}
+107 -29
View File
@@ -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 })
}
})
+32 -5
View File
@@ -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 () => {
+124
View File
@@ -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()
}
})
})
+11
View File
@@ -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],