From 1b3d0a64e8c91a84c546ab7fbe44cdc9fda6d078 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 23 Aug 2026 05:34:20 -0400 Subject: [PATCH 1/5] feat: complete tower and subagent surfaces --- .changeset/add-experimental-tower-command.md | 5 + .changeset/fix-desktop-subagent-activity.md | 5 + .changeset/show-task-outcome-cards.md | 5 + .../pythinker-code/src/tui/commands/config.ts | 3 + .../src/tui/commands/dispatch.ts | 7 + apps/pythinker-code/src/tui/commands/index.ts | 3 +- apps/pythinker-code/src/tui/commands/info.ts | 3 + .../src/tui/commands/registry.ts | 28 ++ .../src/tui/commands/resolve.ts | 6 +- apps/pythinker-code/src/tui/commands/tower.ts | 90 +++++ apps/pythinker-code/src/tui/commands/types.ts | 5 +- .../src/tui/components/chrome/footer.ts | 1 + .../tui/components/messages/status-panel.ts | 8 +- .../src/tui/constant/pythinker-tui.ts | 4 + .../tui/controllers/session-event-handler.ts | 1 + apps/pythinker-code/src/tui/pythinker-tui.ts | 8 +- apps/pythinker-code/src/tui/types.ts | 1 + .../test/tui/commands/experiments.test.ts | 21 ++ .../test/tui/commands/registry.test.ts | 11 + .../test/tui/commands/resolve.test.ts | 1 + .../test/tui/commands/tower.test.ts | 121 +++++++ .../chrome/footer-status-line.test.ts | 1 + .../test/tui/components/chrome/footer.test.ts | 7 + .../tui/components/chrome/welcome.test.ts | 1 + .../components/messages/status-panel.test.ts | 8 + .../test/tui/create-tui-state.test.ts | 1 + .../test/tui/fullscreen-layout.test.ts | 1 + .../tui/pythinker-tui-message-flow.test.ts | 26 ++ .../src/api/daemon/agentEventProjector.ts | 50 +++ .../src/api/daemon/eventReducer.ts | 20 +- apps/pythinker-web/src/api/types.ts | 1 + .../src/components/chat/ActivityRun.vue | 6 +- .../src/components/chat/ChatPane.vue | 2 + .../src/components/chat/ConversationPane.vue | 18 +- .../src/components/chat/NotificationCard.vue | 337 ++++++++++++++++++ .../src/components/chat/TurnFold.vue | 7 +- .../components/chat/tool-calls/AgentTool.vue | 14 +- .../chat/tool-calls/DynamicWorkflowTool.vue | 17 + .../src/components/chatTurnRendering.ts | 62 +++- .../src/components/ui/PanelHeader.vue | 6 +- .../src/composables/dynamicWorkflowGroups.ts | 6 + .../src/composables/messagesToTurns.ts | 39 +- .../src/i18n/locales/en/conversation.ts | 21 ++ apps/pythinker-web/src/lib/taskMerge.ts | 11 +- .../pythinker-web/src/lib/taskNotification.ts | 154 ++++++++ .../src/lib/transcriptToTurns.ts | 31 +- apps/pythinker-web/src/types.ts | 22 ++ apps/pythinker-web/test/activity-run.test.ts | 26 +- .../test/agent-detail-panel.test.ts | 24 +- .../test/agent-event-projector.test.ts | 44 +++ .../test/chat-turn-rendering.test.ts | 51 +++ apps/pythinker-web/test/event-reducer.test.ts | 69 ++++ apps/pythinker-web/test/lib-logic.test.ts | 18 + apps/pythinker-web/test/model-display.test.ts | 74 +++- .../test/task-notification-card.test.ts | 49 +++ .../test/transcript-to-turns.test.ts | 82 +++++ apps/pythinker-web/test/turn-logic.test.ts | 66 ++++ .../agent-core-v2/docs/state-manifest.d.ts | 5 +- .../agent-core-v2/docs/wire-manifest.d.ts | 9 +- .../src/app/sessionManager/sessionProtocol.ts | 2 + .../sessionManager/sessionStatusService.ts | 3 + .../agent-core-v2/src/features/tower/flag.ts | 17 + .../injection/tower-mode-exit-reminder.md | 1 + .../tower-mode-full-reminder.md} | 14 +- .../injection/tower-mode-sparse-reminder.md | 1 + .../tower/injection/towerModeInjection.ts | 68 ++++ .../src/features/tower/skill/skill.ts | 30 -- .../src/features/tower/tools/init/initTool.ts | 24 +- .../tower/tools/teardown/teardownTool.ts | 19 +- .../agent-core-v2/src/features/tower/tower.ts | 2 +- .../src/features/tower/towerFeature.ts | 15 + .../src/features/tower/towerOps.ts | 10 +- .../src/features/tower/towerService.ts | 165 ++++++++- packages/agent-core-v2/src/index.ts | 3 +- .../agentLifecycle/profile/profiles.ts | 2 + packages/agent-core-v2/src/session/errors.ts | 1 + .../app/sessionManager/sessionStatus.test.ts | 5 + .../app/skillCatalog/builtinTower.test.ts | 85 ----- .../features/tower/tools/towerTools.test.ts | 45 ++- .../test/features/tower/towerService.test.ts | 99 +++-- .../test/state/builtinReplayableKeys.ts | 3 +- .../agent-gateway/src/protocol/events-zod.ts | 1 + .../src/routes/sessionAgentConfig.ts | 15 + .../src/services/transcript/coreEventMap.ts | 15 +- .../services/transcript/transcriptService.ts | 36 +- .../src/transport/ws/v1/events.ts | 1 + .../test/services/transcript.test.ts | 122 +++++++ packages/node-sdk/src/rpc.ts | 12 + packages/node-sdk/src/sdk-rpc-client-v2.ts | 22 ++ packages/node-sdk/src/session.ts | 11 + packages/node-sdk/src/types.ts | 1 + .../node-sdk/test/sdk-rpc-client-v2.test.ts | 74 ++++ .../session-plan-compact-usage-resume.test.ts | 24 +- packages/node-sdk/test/v1-v2-parity.test.ts | 51 ++- packages/protocol/src/events.ts | 2 + packages/protocol/src/rest/session.ts | 1 + packages/protocol/src/session.ts | 1 + packages/transcript/src/contract/schema.ts | 2 + packages/transcript/src/history/foldFacts.ts | 15 +- packages/transcript/src/model/meta.ts | 4 +- packages/transcript/src/ops/apply.ts | 9 +- packages/transcript/test/layers.test.ts | 22 ++ packages/transcript/test/store.test.ts | 8 +- 103 files changed, 2526 insertions(+), 260 deletions(-) create mode 100644 .changeset/add-experimental-tower-command.md create mode 100644 .changeset/fix-desktop-subagent-activity.md create mode 100644 .changeset/show-task-outcome-cards.md create mode 100644 apps/pythinker-code/src/tui/commands/tower.ts create mode 100644 apps/pythinker-code/test/tui/commands/tower.test.ts create mode 100644 apps/pythinker-web/src/components/chat/NotificationCard.vue create mode 100644 apps/pythinker-web/src/lib/taskNotification.ts create mode 100644 apps/pythinker-web/test/task-notification-card.test.ts create mode 100644 packages/agent-core-v2/src/features/tower/flag.ts create mode 100644 packages/agent-core-v2/src/features/tower/injection/tower-mode-exit-reminder.md rename packages/agent-core-v2/src/features/tower/{skill/tower.md => injection/tower-mode-full-reminder.md} (91%) create mode 100644 packages/agent-core-v2/src/features/tower/injection/tower-mode-sparse-reminder.md create mode 100644 packages/agent-core-v2/src/features/tower/injection/towerModeInjection.ts delete mode 100644 packages/agent-core-v2/src/features/tower/skill/skill.ts delete mode 100644 packages/agent-core-v2/test/app/skillCatalog/builtinTower.test.ts diff --git a/.changeset/add-experimental-tower-command.md b/.changeset/add-experimental-tower-command.md new file mode 100644 index 000000000..a29d4e674 --- /dev/null +++ b/.changeset/add-experimental-tower-command.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": minor +--- + +Add the experimental `/tower` command for workspace-wide subagent coordination, enabled with `PYTHINKER_CODE_EXPERIMENTAL_TOWER=1`. diff --git a/.changeset/fix-desktop-subagent-activity.md b/.changeset/fix-desktop-subagent-activity.md new file mode 100644 index 000000000..d2a7f8026 --- /dev/null +++ b/.changeset/fix-desktop-subagent-activity.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Remove duplicate live activity and keep narrow subagent panel controls visible. diff --git a/.changeset/show-task-outcome-cards.md b/.changeset/show-task-outcome-cards.md new file mode 100644 index 000000000..dcc1bd2b6 --- /dev/null +++ b/.changeset/show-task-outcome-cards.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Show task outcome cards with subagent model and thinking-effort details in conversations. diff --git a/apps/pythinker-code/src/tui/commands/config.ts b/apps/pythinker-code/src/tui/commands/config.ts index fa4807d69..51fb9dca7 100644 --- a/apps/pythinker-code/src/tui/commands/config.ts +++ b/apps/pythinker-code/src/tui/commands/config.ts @@ -807,6 +807,9 @@ export async function applyExperimentalFeatureChanges( } else { host.showStatus('Experimental features updated.', 'success'); } + if (changes.some((change) => change.id === 'tower')) { + host.showNotice('Tower mode takes effect after restarting Pythinker Code.'); + } host.track('experimental_features_apply', { changed: changes.length }); } catch (error) { host.showError(`Failed to update experimental features: ${formatErrorMessage(error)}`); diff --git a/apps/pythinker-code/src/tui/commands/dispatch.ts b/apps/pythinker-code/src/tui/commands/dispatch.ts index beda56029..76799e83a 100644 --- a/apps/pythinker-code/src/tui/commands/dispatch.ts +++ b/apps/pythinker-code/src/tui/commands/dispatch.ts @@ -69,6 +69,7 @@ import { handleTitleCommand, } from './session'; import { handleDynamicWorkflowCommand } from './dynamic_workflow'; +import { handleTowerCommand } from './tower'; import { handleUndoCommand } from './undo'; import { handleWebCommand } from './web'; @@ -96,6 +97,7 @@ export { showSettingsSelector, } from './config'; export { handleDynamicWorkflowCommand } from './dynamic_workflow'; +export { handleTowerCommand } from './tower'; export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; @@ -269,6 +271,7 @@ function dispatchInlineSkillCombo(host: SlashCommandHost, text: string): boolean pluginCommandMap: host.pluginCommandMap, isStreaming: false, isCompacting: false, + engineV2: host.engineV2, }); if (intent.kind !== 'skill' && intent.kind !== 'message') return false; @@ -306,6 +309,7 @@ async function executeSlashCommand(host: SlashCommandHost, input: string): Promi pluginCommandMap: host.pluginCommandMap, isStreaming: host.state.appState.streamingPhase !== 'idle', isCompacting: host.state.appState.isCompacting, + engineV2: host.engineV2, }); switch (intent.kind) { @@ -573,6 +577,9 @@ async function handleBuiltInSlashCommand( case 'dynamic_workflow': await handleDynamicWorkflowCommand(host, args); return; + case 'tower': + await handleTowerCommand(host, args); + return; case 'compact': await handleCompactCommand(host, args); return; diff --git a/apps/pythinker-code/src/tui/commands/index.ts b/apps/pythinker-code/src/tui/commands/index.ts index d8caba10a..522549f45 100644 --- a/apps/pythinker-code/src/tui/commands/index.ts +++ b/apps/pythinker-code/src/tui/commands/index.ts @@ -23,11 +23,12 @@ export { showSettingsSelector, } from './config'; export { handleDynamicWorkflowCommand } from './dynamic_workflow'; +export { handleTowerCommand } from './tower'; export { handleFeedbackCommand, showMcpServers, showStatusReport, showUsage } from './info'; export { handlePluginsCommand } from './plugins'; export { handleReloadCommand, handleReloadTuiCommand } from './reload'; export { handleGoalCommand, parseGoalCommand, goalObjectiveLengthWarning } from './goal'; -export { goalArgumentCompletions } from './registry'; +export { goalArgumentCompletions, towerArgumentCompletions } from './registry'; export { handleForkCommand, handleInitCommand, handleTitleCommand } from './session'; export { handleUndoCommand } from './undo'; export { handleWebCommand } from './web'; diff --git a/apps/pythinker-code/src/tui/commands/info.ts b/apps/pythinker-code/src/tui/commands/info.ts index c7a71b152..7dfbc6ff6 100644 --- a/apps/pythinker-code/src/tui/commands/info.ts +++ b/apps/pythinker-code/src/tui/commands/info.ts @@ -24,6 +24,7 @@ import { submitFeedbackWithAttachments } from '../../feedback/feedback-attachmen import { formatErrorMessage } from '../utils/event-payload'; import { openUrl } from '#/utils/open-url'; import { promptFeedbackAttachment, promptFeedbackInput } from './prompts'; +import { isExperimentalFlagEnabled } from './experimental-flags'; import type { SlashCommandHost } from './dispatch'; // --------------------------------------------------------------------------- @@ -174,6 +175,8 @@ export async function showStatusReport(host: SlashCommandHost): Promise { thinkingEffort: appState.thinkingEffort, permissionMode: appState.permissionMode, planMode: appState.planMode, + towerMode: appState.towerMode, + towerAvailable: host.engineV2 && isExperimentalFlagEnabled('tower'), contextUsage: appState.contextUsage, contextTokens: appState.contextTokens, maxContextTokens: appState.maxContextTokens, diff --git a/apps/pythinker-code/src/tui/commands/registry.ts b/apps/pythinker-code/src/tui/commands/registry.ts index e819c780b..0560f9454 100644 --- a/apps/pythinker-code/src/tui/commands/registry.ts +++ b/apps/pythinker-code/src/tui/commands/registry.ts @@ -26,6 +26,13 @@ const DYNAMIC_WORKFLOW_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ { value: 'off', description: 'Turn dynamic_workflow mode off' }, ]; +const TOWER_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ + { value: 'status', description: 'Report tower status' }, + { value: 'teardown', description: 'Tear down the tower' }, + { value: 'on', description: 'Turn tower mode on' }, + { value: 'off', description: 'Turn tower mode off' }, +]; + const ADD_DIR_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ { value: 'list', description: 'Show configured additional workspace directories' }, ]; @@ -49,6 +56,11 @@ export function dynamicWorkflowArgumentCompletions(argumentPrefix: string): Auto return completeLeadingArg(DYNAMIC_WORKFLOW_ARG_COMPLETIONS, argumentPrefix); } +/** Argument autocompletion for the `/tower` command. */ +export function towerArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { + return completeLeadingArg(TOWER_ARG_COMPLETIONS, argumentPrefix); +} + /** Argument autocompletion for the `/add-dir` command. */ export function addDirArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { if (isPathLikeAddDirArgument(argumentPrefix)) { @@ -177,6 +189,22 @@ export const BUILTIN_SLASH_COMMANDS = [ completeArgs: dynamicWorkflowArgumentCompletions, availability: 'idle-only', }, + { + name: 'tower', + aliases: [], + description: 'Report tower status, toggle tower mode, or set the tower objective', + priority: 100, + argumentHint: '[status|teardown|on|off] | ', + completeArgs: towerArgumentCompletions, + availability: (args) => { + const sub = args.trim().toLowerCase(); + return sub === '' || sub === 'on' || sub === 'off' || sub === 'status' || sub === 'teardown' + ? 'always' + : 'idle-only'; + }, + experimentalFlag: 'tower', + requiresEngineV2: true, + }, { name: 'model', aliases: [], diff --git a/apps/pythinker-code/src/tui/commands/resolve.ts b/apps/pythinker-code/src/tui/commands/resolve.ts index 5156a58cd..3caaf2fb0 100644 --- a/apps/pythinker-code/src/tui/commands/resolve.ts +++ b/apps/pythinker-code/src/tui/commands/resolve.ts @@ -51,6 +51,7 @@ export interface ResolveSlashCommandInput { readonly pluginCommandMap: ReadonlyMap; readonly isStreaming: boolean; readonly isCompacting: boolean; + readonly engineV2: boolean; } export function resolveSlashCommandInput(options: ResolveSlashCommandInput): SlashCommandIntent { @@ -61,7 +62,8 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla // `command` is a literal union where only some members carry `experimentalFlag`; widen to read it. if ( command !== undefined && - isExperimentalFlagEnabled((command as PythinkerSlashCommand).experimentalFlag) + isExperimentalFlagEnabled((command as PythinkerSlashCommand).experimentalFlag) && + (!(command as PythinkerSlashCommand).requiresEngineV2 || options.engineV2) ) { const busyReason = slashCommandBusyReason(options); if ( @@ -87,7 +89,7 @@ export function resolveSlashCommandInput(options: ResolveSlashCommandInput): Sla // Skill activations are never blocked by a busy session: the TUI queues // them behind the running turn exactly like normal messages (see // sendSkillActivation), and Ctrl-S steers them as real activations, so - // commands like /tower can be issued any time. + // skill commands can be issued any time. return { kind: 'skill', commandName: parsed.name, diff --git a/apps/pythinker-code/src/tui/commands/tower.ts b/apps/pythinker-code/src/tui/commands/tower.ts new file mode 100644 index 000000000..164f399d4 --- /dev/null +++ b/apps/pythinker-code/src/tui/commands/tower.ts @@ -0,0 +1,90 @@ +import type { Session } from '@pymodel/pythinker-code-sdk'; + +import { + LLM_NOT_SET_MESSAGE, + NO_ACTIVE_SESSION_MESSAGE, + TOWER_STATUS_PROMPT, + TOWER_TEARDOWN_PROMPT, +} from '../constant/pythinker-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +export async function handleTowerCommand(host: SlashCommandHost, args: string): Promise { + const input = args.trim(); + const sub = input.toLowerCase(); + + if (sub === 'on') { + await applyTowerMode(host, true); + return; + } + if (sub === 'off') { + await applyTowerMode(host, false); + return; + } + if (sub === '' || sub === 'status') { + host.sendNormalUserInput(TOWER_STATUS_PROMPT); + return; + } + if (sub === 'teardown') { + host.sendNormalUserInput(TOWER_TEARDOWN_PROMPT); + return; + } + + await startTowerObjective(host, input); +} + +async function startTowerObjective(host: SlashCommandHost, objective: string): Promise { + const wasActive = host.state.appState.towerMode; + if (host.state.appState.model.trim().length === 0) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + if (!(await setTowerMode(host, true))) return; + if (!wasActive) host.showNotice('Tower mode: ON'); + host.sendNormalUserInput(objective); +} + +async function applyTowerMode(host: SlashCommandHost, enabled: boolean): Promise { + const wasActive = host.state.appState.towerMode; + if (!(await setTowerMode(host, enabled))) return; + if (wasActive === enabled) { + host.showStatus(`Tower mode is already ${enabled ? 'on' : 'off'}.`); + return; + } + host.showNotice(enabled ? 'Tower mode: ON' : 'Tower mode: OFF'); +} + +async function setTowerMode(host: SlashCommandHost, enabled: boolean): Promise { + const session = await requireSessionEnsured(host); + if (session === undefined) return false; + try { + await session.setTowerMode(enabled); + const status = await session.getStatus(); + const effective = status.towerMode ?? false; + if (effective !== enabled) { + host.setAppState({ towerMode: effective }); + host.showError( + enabled + ? 'Tower mode could not be enabled — another session owns this workspace tower, or the experiment is off or needs a restart.' + : 'Tower mode could not be disabled.', + ); + return false; + } + } catch (error) { + host.showError( + `Failed to ${enabled ? 'enable' : 'disable'} tower mode: ${formatErrorMessage(error)}`, + ); + return false; + } + host.setAppState({ towerMode: enabled }); + return true; +} + +async function requireSessionEnsured(host: SlashCommandHost): Promise { + if (host.session !== undefined) return host.session; + if (!host.engineV2) { + host.showError(NO_ACTIVE_SESSION_MESSAGE); + return undefined; + } + return host.ensureSession(); +} diff --git a/apps/pythinker-code/src/tui/commands/types.ts b/apps/pythinker-code/src/tui/commands/types.ts index 103f6ff1c..3431a17a6 100644 --- a/apps/pythinker-code/src/tui/commands/types.ts +++ b/apps/pythinker-code/src/tui/commands/types.ts @@ -1,5 +1,4 @@ import type { AutocompleteItem, SlashCommand } from '@pymodel/pi-tui'; -import type { FlagId } from '@pymodel/pythinker-code-sdk'; export type SlashCommandAvailability = 'always' | 'idle-only'; @@ -10,7 +9,9 @@ export interface PythinkerSlashCommand extends Sla readonly priority?: number; readonly availability?: SlashCommandAvailability | ((args: string) => SlashCommandAvailability); /** When set, the command is hidden from the palette and blocked unless this flag is enabled. */ - readonly experimentalFlag?: FlagId; + readonly experimentalFlag?: string; + /** When set, the command is hidden and unresolved on the legacy engine. */ + readonly requiresEngineV2?: boolean; /** * Generic argument autocompletion. `argumentPrefix` is the text typed after * `/ `; return suggestions or `null`. Declared as a plain function diff --git a/apps/pythinker-code/src/tui/components/chrome/footer.ts b/apps/pythinker-code/src/tui/components/chrome/footer.ts index a28e5a18f..cdee5e26a 100644 --- a/apps/pythinker-code/src/tui/components/chrome/footer.ts +++ b/apps/pythinker-code/src/tui/components/chrome/footer.ts @@ -409,6 +409,7 @@ export class FooterComponent implements Component { if (state.permissionMode === 'yolo') modes.push(chalk.hex(colors.warning).bold('yolo')); if (state.planMode) modes.push(chalk.hex(colors.primary).bold('plan')); if (state.dynamicWorkflowMode) modes.push(chalk.hex(colors.accent).bold('dynamic_workflow')); + if (state.towerMode) modes.push(chalk.hex(colors.accent).bold('tower')); if (modes.length > 0) slots['mode'] = [modes.join(' ')]; const goalBadge = formatGoalBadge(state.goal, colors, this.goalWallClockMs(state.goal)); diff --git a/apps/pythinker-code/src/tui/components/messages/status-panel.ts b/apps/pythinker-code/src/tui/components/messages/status-panel.ts index 3f539c605..61d2007bb 100644 --- a/apps/pythinker-code/src/tui/components/messages/status-panel.ts +++ b/apps/pythinker-code/src/tui/components/messages/status-panel.ts @@ -44,6 +44,8 @@ export interface StatusReportOptions { readonly thinkingEffort: ThinkingEffort; readonly permissionMode: PermissionMode; readonly planMode: boolean; + readonly towerMode: boolean; + readonly towerAvailable: boolean; readonly contextUsage: number; readonly contextTokens: number; readonly maxContextTokens: number; @@ -106,14 +108,18 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { const permission = options.status?.permission ?? options.permissionMode; const planMode = options.status?.planMode ?? options.planMode; + const towerMode = options.status?.towerMode ?? options.towerMode; const sessionId = options.sessionId.trim().length > 0 ? options.sessionId : 'none'; const rows: FieldRow[] = [ { label: 'Model', value: formatModelStatus(options) }, { label: 'Directory', value: options.workDir }, { label: 'Permissions', value: permission }, { label: 'Plan mode', value: planMode ? 'on' : 'off' }, - { label: 'Session', value: sessionId }, ]; + if (options.towerAvailable) { + rows.push({ label: 'Tower mode', value: towerMode ? 'on' : 'off' }); + } + rows.push({ label: 'Session', value: sessionId }); const title = options.sessionTitle?.trim(); if (title !== undefined && title.length > 0) rows.push({ label: 'Title', value: title }); if (options.statusError !== undefined) { diff --git a/apps/pythinker-code/src/tui/constant/pythinker-tui.ts b/apps/pythinker-code/src/tui/constant/pythinker-tui.ts index 642323539..45c77d177 100644 --- a/apps/pythinker-code/src/tui/constant/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/constant/pythinker-tui.ts @@ -10,6 +10,10 @@ export const MAIN_AGENT_ID = 'main'; export const OAUTH_LOGIN_REQUIRED_STARTUP_NOTICE = 'OAuth login expired. Send /login to login.'; export const SESSIONLESS_STARTUP_NOTICE = 'No session yet — one will be created on your first message.'; +export const TOWER_STATUS_PROMPT = + 'Report the current tower status: call TowerStatus and give a compact summary.'; +export const TOWER_TEARDOWN_PROMPT = + 'Tear down the tower: call TowerTeardown and report what it did. It refuses to destroy dirty worktrees unless forced.'; export const EXIT_CONFIRM_WINDOW_MS = 1500; // Time window for treating two consecutive Esc presses as a double-Esc, which // opens the undo selector. Kept short (double-click feel) so two deliberate diff --git a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts index 15c2fe3da..2e77e632d 100644 --- a/apps/pythinker-code/src/tui/controllers/session-event-handler.ts +++ b/apps/pythinker-code/src/tui/controllers/session-event-handler.ts @@ -733,6 +733,7 @@ export class SessionEventHandler { if (event.maxContextTokens !== undefined) patch.maxContextTokens = event.maxContextTokens; if (event.planMode !== undefined) patch.planMode = event.planMode; if (event.dynamicWorkflowMode !== undefined) patch.dynamicWorkflowMode = event.dynamicWorkflowMode; + if (event.towerMode !== undefined) patch.towerMode = event.towerMode; if (event.permission !== undefined) { patch.permissionMode = event.permission; } diff --git a/apps/pythinker-code/src/tui/pythinker-tui.ts b/apps/pythinker-code/src/tui/pythinker-tui.ts index 25fa052cf..a82f89016 100644 --- a/apps/pythinker-code/src/tui/pythinker-tui.ts +++ b/apps/pythinker-code/src/tui/pythinker-tui.ts @@ -247,6 +247,7 @@ function createInitialAppState(input: PythinkerTUIStartupInput): AppState { planMode: input.cliOptions.plan, inputMode: 'prompt', dynamicWorkflowMode: false, + towerMode: false, thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, @@ -451,8 +452,10 @@ export class PythinkerTUI { // ========================================================================= private getSlashCommands(): readonly PythinkerSlashCommand[] { - const builtins = sortSlashCommands(BUILTIN_SLASH_COMMANDS).filter((command) => - isExperimentalFlagEnabled(command.experimentalFlag), + const builtins = sortSlashCommands(BUILTIN_SLASH_COMMANDS).filter( + (command) => + isExperimentalFlagEnabled(command.experimentalFlag) && + (!command.requiresEngineV2 || this.engineV2), ); return [...builtins, ...this.skillCommands, ...this.pluginCommands]; } @@ -2224,6 +2227,7 @@ export class PythinkerTUI { permissionMode: status.permission, planMode: status.planMode, dynamicWorkflowMode: status.dynamicWorkflowMode ?? false, + towerMode: status.towerMode ?? false, contextTokens: status.contextTokens, maxContextTokens: status.maxContextTokens, contextUsage: status.contextUsage, diff --git a/apps/pythinker-code/src/tui/types.ts b/apps/pythinker-code/src/tui/types.ts index ed6833cb0..4e3172633 100644 --- a/apps/pythinker-code/src/tui/types.ts +++ b/apps/pythinker-code/src/tui/types.ts @@ -39,6 +39,7 @@ export interface AppState { /** 'bash' when the editor is in `!` shell-command mode. */ inputMode: 'prompt' | 'bash'; dynamicWorkflowMode: boolean; + towerMode: boolean; /** Live thinking effort of the active session (e.g. 'off', 'on', 'high'); * mirrors the runtime. The single source of truth for the thinking state in * the TUI. */ diff --git a/apps/pythinker-code/test/tui/commands/experiments.test.ts b/apps/pythinker-code/test/tui/commands/experiments.test.ts index e41a75ac1..d253824a5 100644 --- a/apps/pythinker-code/test/tui/commands/experiments.test.ts +++ b/apps/pythinker-code/test/tui/commands/experiments.test.ts @@ -50,6 +50,7 @@ function makeHost() { restoreEditor: vi.fn(), showStatus: vi.fn(), showError: vi.fn(), + showNotice: vi.fn(), track: vi.fn(), } as unknown as SlashCommandHost & { harness: { @@ -113,4 +114,24 @@ describe('experimental feature command handlers', () => { 'textMuted', ); }); + + it('shows a restart notice when the tower flag changes', async () => { + const host = makeHost(); + + await applyExperimentalFeatureChanges(host, [{ id: 'tower', enabled: true }]); + + expect(host.showNotice).toHaveBeenCalledWith( + 'Tower mode takes effect after restarting Pythinker Code.', + ); + }); + + it('does not show the restart notice for other flags', async () => { + const host = makeHost(); + + await applyExperimentalFeatureChanges(host, [ + { id: 'micro_compaction', enabled: false }, + ]); + + expect(host.showNotice).not.toHaveBeenCalled(); + }); }); diff --git a/apps/pythinker-code/test/tui/commands/registry.test.ts b/apps/pythinker-code/test/tui/commands/registry.test.ts index fa2bb88a8..0ecc54feb 100644 --- a/apps/pythinker-code/test/tui/commands/registry.test.ts +++ b/apps/pythinker-code/test/tui/commands/registry.test.ts @@ -6,6 +6,7 @@ import { addDirArgumentCompletions, sortSlashCommands, dynamicWorkflowArgumentCompletions, + towerArgumentCompletions, type PythinkerSlashCommand, } from '#/tui/commands/index'; import { describe, expect, it } from 'vitest'; @@ -75,6 +76,16 @@ describe('built-in slash command registry', () => { expect(values('Ship feature X')).toBeNull(); }); + it('registers tower as an engine-v2 experiment', () => { + const tower = findBuiltInSlashCommand('tower'); + expect(tower).toBeDefined(); + expect((tower as PythinkerSlashCommand).experimentalFlag).toBe('tower'); + expect((tower as PythinkerSlashCommand).requiresEngineV2).toBe(true); + expect(resolveSlashCommandAvailability(tower!, 'status')).toBe('always'); + expect(resolveSlashCommandAvailability(tower!, 'Ship feature X')).toBe('idle-only'); + expect(towerArgumentCompletions('')).toHaveLength(4); + }); + it('offers add-dir list and directory argument completions', () => { const values = (prefix: string): string[] | null => { const items = addDirArgumentCompletions(prefix); diff --git a/apps/pythinker-code/test/tui/commands/resolve.test.ts b/apps/pythinker-code/test/tui/commands/resolve.test.ts index 45d00a77c..41ed38564 100644 --- a/apps/pythinker-code/test/tui/commands/resolve.test.ts +++ b/apps/pythinker-code/test/tui/commands/resolve.test.ts @@ -17,6 +17,7 @@ function resolve( pluginCommandMap: new Map(), isStreaming: false, isCompacting: false, + engineV2: true, ...overrides, }); } diff --git a/apps/pythinker-code/test/tui/commands/tower.test.ts b/apps/pythinker-code/test/tui/commands/tower.test.ts new file mode 100644 index 000000000..01219b428 --- /dev/null +++ b/apps/pythinker-code/test/tui/commands/tower.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Session } from '@pymodel/pythinker-code-sdk'; + +import { handleTowerCommand } from '#/tui/commands/index'; +import type { SlashCommandHost } from '#/tui/commands/dispatch'; +import { + LLM_NOT_SET_MESSAGE, + TOWER_STATUS_PROMPT, + TOWER_TEARDOWN_PROMPT, +} from '#/tui/constant/pythinker-tui'; + +function makeHost( + overrides: { + hasSession?: boolean; + towerMode?: boolean; + engineV2?: boolean; + refuseTowerEntry?: boolean; + model?: string; + } = {}, +) { + let engineMode = overrides.towerMode ?? false; + const session = { + setTowerMode: vi.fn(async (enabled: boolean) => { + if (!(overrides.refuseTowerEntry && enabled)) engineMode = enabled; + }), + getStatus: vi.fn(async () => ({ towerMode: engineMode })), + }; + const host = { + state: { + appState: { + towerMode: overrides.towerMode ?? false, + model: overrides.model ?? 'test-model', + }, + }, + engineV2: overrides.engineV2 ?? true, + session: overrides.hasSession === false ? undefined : session, + ensureSession: vi.fn(async () => { + host.session = session as unknown as Session; + return session as unknown as Session; + }), + requireSession: () => { + if (host.session === undefined) throw new Error('No active session'); + return host.session; + }, + setAppState: vi.fn((patch: Record) => Object.assign(host.state.appState, patch)), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + sendNormalUserInput: vi.fn(), + } as unknown as SlashCommandHost; + return { host, session }; +} + +describe('handleTowerCommand', () => { + it('routes status and teardown without changing the mode', async () => { + const { host, session } = makeHost({ towerMode: true }); + + await handleTowerCommand(host, ''); + await handleTowerCommand(host, 'status'); + await handleTowerCommand(host, 'teardown'); + + expect(host.sendNormalUserInput).toHaveBeenNthCalledWith(1, TOWER_STATUS_PROMPT); + expect(host.sendNormalUserInput).toHaveBeenNthCalledWith(2, TOWER_STATUS_PROMPT); + expect(host.sendNormalUserInput).toHaveBeenNthCalledWith(3, TOWER_TEARDOWN_PROMPT); + expect(session.setTowerMode).not.toHaveBeenCalled(); + }); + + it('turns tower mode on and off', async () => { + const { host, session } = makeHost(); + + await handleTowerCommand(host, 'on'); + await handleTowerCommand(host, 'off'); + + expect(session.setTowerMode).toHaveBeenNthCalledWith(1, true); + expect(session.setTowerMode).toHaveBeenNthCalledWith(2, false); + expect(host.showNotice).toHaveBeenNthCalledWith(1, 'Tower mode: ON'); + expect(host.showNotice).toHaveBeenNthCalledWith(2, 'Tower mode: OFF'); + }); + + it('enables tower mode before sending an objective', async () => { + const { host, session } = makeHost(); + + await handleTowerCommand(host, 'Ship feature X'); + + expect(session.setTowerMode).toHaveBeenCalledWith(true); + expect(host.sendNormalUserInput).toHaveBeenCalledWith('Ship feature X'); + }); + + it('does not mutate tower mode when an objective has no model', async () => { + const { host, session } = makeHost({ model: '' }); + + await handleTowerCommand(host, 'Ship feature X'); + + expect(host.showError).toHaveBeenCalledWith(LLM_NOT_SET_MESSAGE); + expect(session.setTowerMode).not.toHaveBeenCalled(); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('does not send an objective when the engine refuses tower entry', async () => { + const { host } = makeHost({ refuseTowerEntry: true }); + + await handleTowerCommand(host, 'Ship feature X'); + + expect(host.showError).toHaveBeenCalledWith(expect.stringContaining('could not be enabled')); + expect(host.setAppState).toHaveBeenCalledWith({ towerMode: false }); + expect(host.sendNormalUserInput).not.toHaveBeenCalled(); + }); + + it('lazy-creates a v2 session and rejects a missing legacy session', async () => { + const current = makeHost({ hasSession: false }); + await handleTowerCommand(current.host, 'on'); + expect(current.host.ensureSession).toHaveBeenCalledOnce(); + expect(current.session.setTowerMode).toHaveBeenCalledWith(true); + + const legacy = makeHost({ hasSession: false, engineV2: false }); + await handleTowerCommand(legacy.host, 'on'); + expect(legacy.host.showError).toHaveBeenCalledWith(expect.stringContaining('session')); + expect(legacy.host.ensureSession).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/pythinker-code/test/tui/components/chrome/footer-status-line.test.ts b/apps/pythinker-code/test/tui/components/chrome/footer-status-line.test.ts index 1c76901b1..52849ac9d 100644 --- a/apps/pythinker-code/test/tui/components/chrome/footer-status-line.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/footer-status-line.test.ts @@ -33,6 +33,7 @@ const baseState: AppState = { planMode: false, inputMode: 'prompt', dynamicWorkflowMode: false, + towerMode: false, theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts index e1d79d0cf..032b32ff5 100644 --- a/apps/pythinker-code/test/tui/components/chrome/footer.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/footer.test.ts @@ -52,6 +52,7 @@ const appState: AppState = { planMode: false, inputMode: 'prompt', dynamicWorkflowMode: false, + towerMode: false, theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, @@ -94,6 +95,12 @@ describe('FooterComponent', () => { expect(codes.has(RAINBOW_GREEN)).toBe(false); }); + it('shows tower mode in the footer', () => { + const footer = new FooterComponent({ ...appState, towerMode: true }); + + expect(footer.render(120).join('\n')).toContain('tower'); + }); + it('repaints from the active palette on the next render (no setColors needed)', () => { const footer = new FooterComponent(appState); const before = footer.render(120).join('\n'); diff --git a/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts b/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts index c38c88e81..7cba70e17 100644 --- a/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/pythinker-code/test/tui/components/chrome/welcome.test.ts @@ -33,6 +33,7 @@ const appState: AppState = { planMode: false, inputMode: 'prompt', dynamicWorkflowMode: false, + towerMode: false, theme: 'dark', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts b/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts index 053b7ed7a..e35369cf1 100644 --- a/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/pythinker-code/test/tui/components/messages/status-panel.test.ts @@ -17,6 +17,8 @@ describe('status panel report lines', () => { thinkingEffort: 'on', permissionMode: 'manual', planMode: false, + towerMode: true, + towerAvailable: true, contextUsage: 0.25, contextTokens: 2500, maxContextTokens: 10000, @@ -33,6 +35,7 @@ describe('status panel report lines', () => { thinkingEffort: 'high', permission: 'auto', planMode: true, + towerMode: true, contextTokens: 3000, maxContextTokens: 12000, contextUsage: 0.25, @@ -56,6 +59,7 @@ describe('status panel report lines', () => { expect(output).toContain('Directory /tmp/project'); expect(output).toContain('Permissions auto'); expect(output).toContain('Plan mode on'); + expect(output).toContain('Tower mode on'); expect(output).toContain('Session ses-1'); expect(output).toContain('Title Implement status'); expect(output).toContain('Context window'); @@ -79,6 +83,8 @@ describe('status panel report lines', () => { thinkingEffort: 'off', permissionMode: 'manual', planMode: false, + towerMode: false, + towerAvailable: false, contextUsage: 0, contextTokens: 0, maxContextTokens: 0, @@ -117,6 +123,8 @@ describe('status panel report lines', () => { thinkingEffort: 'off', permissionMode: 'manual', planMode: false, + towerMode: false, + towerAvailable: false, contextUsage: 0, contextTokens: 0, maxContextTokens: 0, diff --git a/apps/pythinker-code/test/tui/create-tui-state.test.ts b/apps/pythinker-code/test/tui/create-tui-state.test.ts index 5e27cd94e..7120ee04f 100644 --- a/apps/pythinker-code/test/tui/create-tui-state.test.ts +++ b/apps/pythinker-code/test/tui/create-tui-state.test.ts @@ -16,6 +16,7 @@ function fakeInitialAppState(): AppState { planMode: false, inputMode: 'prompt', dynamicWorkflowMode: false, + towerMode: false, thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, diff --git a/apps/pythinker-code/test/tui/fullscreen-layout.test.ts b/apps/pythinker-code/test/tui/fullscreen-layout.test.ts index 395430083..d604864bb 100644 --- a/apps/pythinker-code/test/tui/fullscreen-layout.test.ts +++ b/apps/pythinker-code/test/tui/fullscreen-layout.test.ts @@ -33,6 +33,7 @@ function fakeInitialAppState(): AppState { planMode: false, inputMode: 'prompt', dynamicWorkflowMode: false, + towerMode: false, thinkingEffort: 'off', contextUsage: 0, contextTokens: 0, diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index daad08a1f..e9c8a211b 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -5467,6 +5467,32 @@ command = "vim" expect(driver.state.appState.thinkingEffort).toBe('mid'); }); + it('applies tower mode from status updates', async () => { + const { driver } = await makeDriver(); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + towerMode: true, + } as Event, + vi.fn(), + ); + expect(driver.state.appState.towerMode).toBe(true); + + driver.sessionEventHandler.handleEvent( + { + type: 'agent.status.updated', + agentId: 'main', + sessionId: 'ses-1', + towerMode: false, + } as Event, + vi.fn(), + ); + expect(driver.state.appState.towerMode).toBe(false); + }); + it('renders dynamic_workflow mode markers from /dynamic_workflow commands, not tool-triggered status updates', async () => { const { driver } = await makeDriver(); diff --git a/apps/pythinker-web/src/api/daemon/agentEventProjector.ts b/apps/pythinker-web/src/api/daemon/agentEventProjector.ts index ac2bc0932..5e9f675a0 100644 --- a/apps/pythinker-web/src/api/daemon/agentEventProjector.ts +++ b/apps/pythinker-web/src/api/daemon/agentEventProjector.ts @@ -185,6 +185,15 @@ function stringField(source: Record, key: string): string | und return typeof value === 'string' ? value : undefined; } +function escapeXml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + function numberField(source: Record, key: string): number | undefined { const value = source[key]; return typeof value === 'number' && Number.isFinite(value) ? value : undefined; @@ -1209,6 +1218,7 @@ export function createAgentProjector(): AgentProjector { subagentPhase: 'completed', status: 'completed', completedAt: new Date().toISOString(), + completedAtEstimated: true, outputPreview, }); if (task) out.push({ type: 'taskCreated', sessionId, task }); @@ -1228,6 +1238,7 @@ export function createAgentProjector(): AgentProjector { subagentPhase: 'failed', status: 'failed', completedAt: new Date().toISOString(), + completedAtEstimated: true, outputPreview, }); if (task) out.push({ type: 'taskCreated', sessionId, task }); @@ -1241,6 +1252,44 @@ export function createAgentProjector(): AgentProjector { break; } + case 'task.notified': { + const notificationType = stringField(p ?? {}, 'notificationType'); + const sourceKind = stringField(p ?? {}, 'sourceKind'); + const sourceId = stringField(p ?? {}, 'sourceId'); + if (!notificationType || !sourceKind || !sourceId) break; + const state = notificationType.startsWith('task.') + ? notificationType.slice('task.'.length) + : notificationType; + const notificationId = `task:${sourceId}:${state}`; + const title = stringField(p ?? {}, 'title') ?? ''; + const severity = stringField(p ?? {}, 'severity') ?? ''; + const body = stringField(p ?? {}, 'body') ?? ''; + const xml = + `\n` + + (title !== '' ? `Title: ${escapeXml(title)}\n` : '') + + (severity !== '' ? `Severity: ${escapeXml(severity)}\n` : '') + + (body !== '' ? `${escapeXml(body)}\n` : '') + + ''; + const message: AppMessage = { + id: `task_ntf_${notificationId}`, + sessionId, + role: 'user', + content: [{ type: 'text', text: xml }], + createdAt: new Date().toISOString(), + metadata: { + origin: { + kind: 'task', + taskId: sourceId, + status: state, + notificationId, + }, + }, + }; + s.messages.push(message); + out.push({ type: 'messageCreated', message: cloneMessage(message) }); + break; + } + // ----------------------------------------------------------------------- case 'error': { // Fold into an unknown event so the reducer surfaces it as a structured @@ -1543,6 +1592,7 @@ const KNOWN_AGENT_CORE_TYPES = new Set([ 'subagent.failed', 'task.started', 'task.terminated', + 'task.notified', 'background.task.started', 'background.task.terminated', 'cron.fired', diff --git a/apps/pythinker-web/src/api/daemon/eventReducer.ts b/apps/pythinker-web/src/api/daemon/eventReducer.ts index f77179767..907680f84 100644 --- a/apps/pythinker-web/src/api/daemon/eventReducer.ts +++ b/apps/pythinker-web/src/api/daemon/eventReducer.ts @@ -721,6 +721,11 @@ export function reduceAppEvent( event.task.kind === 'subagent' && event.task.status === 'running' && event.task.subagentPhase === 'queued'; + const keepsAuthoritativeCompletion = + !startsNewSubagentRun && + previous.completedAt !== undefined && + previous.completedAtEstimated !== true && + event.task.completedAtEstimated === true; // The projected task does not carry reducer-owned accumulated progress; // preserve it across the replacement so subagent output keeps growing, // except when an explicit queued spawn starts a new run for a terminal id. @@ -730,6 +735,10 @@ export function reduceAppEvent( ...event.task, outputLines: startsNewSubagentRun ? event.task.outputLines : previous.outputLines, text: startsNewSubagentRun ? event.task.text : previous.text, + completedAt: keepsAuthoritativeCompletion ? previous.completedAt : event.task.completedAt, + completedAtEstimated: keepsAuthoritativeCompletion + ? previous.completedAtEstimated + : event.task.completedAtEstimated, // A post-refresh lifecycle event re-projects the task with skeleton // metadata; don't let its placeholder clobber the roster-seeded // description. @@ -780,12 +789,15 @@ export function reduceAppEvent( const sid = event.sessionId; const list = next.tasksBySession[sid] ?? []; next.tasksBySession[sid] = list.map((t) => { - if (t.id !== event.taskId) return t; + if (t.id !== event.taskId && t.backgroundTaskId !== event.taskId) return t; + const terminal = t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled'; return { ...t, - status: event.status, - outputPreview: event.outputPreview, - outputBytes: event.outputBytes, + status: terminal ? t.status : event.status, + completedAt: t.completedAt ?? new Date().toISOString(), + completedAtEstimated: t.completedAt === undefined ? true : t.completedAtEstimated, + outputPreview: event.outputPreview ?? t.outputPreview, + outputBytes: event.outputBytes ?? t.outputBytes, }; }); break; diff --git a/apps/pythinker-web/src/api/types.ts b/apps/pythinker-web/src/api/types.ts index 8e27284eb..0f21fce6e 100644 --- a/apps/pythinker-web/src/api/types.ts +++ b/apps/pythinker-web/src/api/types.ts @@ -327,6 +327,7 @@ export interface AppTask { createdAt: string; startedAt?: string; completedAt?: string; + completedAtEstimated?: boolean; outputPreview?: string; outputBytes?: number; agentId?: string; diff --git a/apps/pythinker-web/src/components/chat/ActivityRun.vue b/apps/pythinker-web/src/components/chat/ActivityRun.vue index 4efdd38f7..cf4a27848 100644 --- a/apps/pythinker-web/src/components/chat/ActivityRun.vue +++ b/apps/pythinker-web/src/components/chat/ActivityRun.vue @@ -358,7 +358,7 @@ function isItemStreaming(item: RunItem): boolean {
+ + + + diff --git a/apps/pythinker-web/src/components/chat/TurnFold.vue b/apps/pythinker-web/src/components/chat/TurnFold.vue index 5eb7d3450..1a39c2a63 100644 --- a/apps/pythinker-web/src/components/chat/TurnFold.vue +++ b/apps/pythinker-web/src/components/chat/TurnFold.vue @@ -17,6 +17,7 @@ import Markdown from './Markdown.vue'; import ThinkingBlock from './ThinkingBlock.vue'; import ToolCall from './ToolCall.vue'; import ActivityRun from './ActivityRun.vue'; +import NotificationCard from './NotificationCard.vue'; const props = withDefaults( defineProps<{ @@ -244,6 +245,10 @@ function runStreaming(block: Extract +
@@ -293,4 +298,4 @@ function runStreaming(block: Extract \ No newline at end of file + diff --git a/apps/pythinker-web/src/components/chat/tool-calls/AgentTool.vue b/apps/pythinker-web/src/components/chat/tool-calls/AgentTool.vue index 73bb8583b..0681a8ebf 100644 --- a/apps/pythinker-web/src/components/chat/tool-calls/AgentTool.vue +++ b/apps/pythinker-web/src/components/chat/tool-calls/AgentTool.vue @@ -63,12 +63,18 @@ const status = computed<'running' | 'ok' | 'error'>(() => props.tool.status as ' const label = computed(() => toolLabel(props.tool.name)); const glyph = computed(() => toolGlyph(props.tool.name)); const summary = computed(() => input.value.description || input.value.subagentType || ''); -// The Agent tool input carries `run_in_background` (background/foreground); -// model/effort are not on the wire ToolCall or any task DTO, so the chip is -// the only run-mode signal rendered here. const runModeLabel = computed(() => input.value.runInBackground ? t('tools.agent.background') : t('tools.agent.foreground'), ); +const resolveAgentModel = inject< + (toolCallId: string) => { display?: string; effort?: string } | undefined +>('resolveAgentModel'); +const runMetadata = computed(() => { + const resolved = resolveAgentModel?.(props.tool.id); + return [runModeLabel.value, resolved?.display, resolved?.effort] + .filter((part): part is string => part !== undefined && part !== '') + .join(' · '); +}); // Hide the "Open detail" button when no live/background subagent task matches // this tool call (e.g. a completed foreground subagent after a page refresh) — @@ -105,7 +111,7 @@ watch( @toggle="toggle" >