feat(team): improve runtime provider workflows
This commit is contained in:
parent
4087060cca
commit
445932e45b
84 changed files with 7004 additions and 998 deletions
17
.mcp.json
Normal file
17
.mcp.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"playwright-electron": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@playwright/mcp@0.0.75",
|
||||
"--cdp-endpoint",
|
||||
"http://127.0.0.1:9222",
|
||||
"--caps",
|
||||
"devtools",
|
||||
"--console-level",
|
||||
"info"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
34
README.md
34
README.md
|
|
@ -278,6 +278,9 @@ pnpm dev
|
|||
|
||||
`pnpm dev` starts the desktop Electron app. Do not start a browser/web dev server for normal development; that path is limited and is not the supported way to run agent teams locally.
|
||||
|
||||
Use `pnpm dev:mcp` when you want an MCP browser/debugging tool to attach to the current
|
||||
Electron renderer through the local Chrome DevTools Protocol endpoint on `127.0.0.1:9222`.
|
||||
|
||||
The desktop app auto-discovers Claude Code projects from `~/.claude/`.
|
||||
|
||||
### Debug teammate runtimes
|
||||
|
|
@ -309,21 +312,22 @@ local packaging.
|
|||
|
||||
### Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `pnpm dev` | Desktop app development with hot reload |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm typecheck` | TypeScript type checking |
|
||||
| `pnpm lint` | Lint (no auto-fix) |
|
||||
| `pnpm lint:fix` | Lint and auto-fix |
|
||||
| `pnpm format` | Format code with Prettier |
|
||||
| `pnpm test` | Run all tests |
|
||||
| `pnpm test:watch` | Watch mode |
|
||||
| `pnpm test:coverage` | Coverage report |
|
||||
| `pnpm test:coverage:critical` | Critical path coverage |
|
||||
| `pnpm check` | Full quality gate (types + lint + test + build) |
|
||||
| `pnpm fix` | Lint fix + format |
|
||||
| `pnpm quality` | Full check + format check + knip |
|
||||
| Command | Description |
|
||||
| ----------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `pnpm dev` | Desktop app development with hot reload |
|
||||
| `pnpm dev:mcp` | Desktop app development with hot reload and local CDP debugging on port 9222 |
|
||||
| `pnpm build` | Production build |
|
||||
| `pnpm typecheck` | TypeScript type checking |
|
||||
| `pnpm lint` | Lint (no auto-fix) |
|
||||
| `pnpm lint:fix` | Lint and auto-fix |
|
||||
| `pnpm format` | Format code with Prettier |
|
||||
| `pnpm test` | Run all tests |
|
||||
| `pnpm test:watch` | Watch mode |
|
||||
| `pnpm test:coverage` | Coverage report |
|
||||
| `pnpm test:coverage:critical` | Critical path coverage |
|
||||
| `pnpm check` | Full quality gate (types + lint + test + build) |
|
||||
| `pnpm fix` | Lint fix + format |
|
||||
| `pnpm quality` | Full check + format check + knip |
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -305,6 +305,7 @@ function getStatusIcon(status: string): string {
|
|||
<style scoped>
|
||||
.comparison-section {
|
||||
position: relative;
|
||||
--comparison-sticky-header-offset: 76px;
|
||||
}
|
||||
|
||||
.comparison-section__header {
|
||||
|
|
@ -354,12 +355,13 @@ function getStatusIcon(status: string): string {
|
|||
|
||||
/* Header */
|
||||
.comparison-table thead {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
z-index: 2;
|
||||
position: static;
|
||||
}
|
||||
|
||||
.comparison-table__th {
|
||||
position: sticky;
|
||||
top: var(--comparison-sticky-header-offset);
|
||||
z-index: 3;
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
|
|
@ -382,7 +384,7 @@ function getStatusIcon(status: string): string {
|
|||
.comparison-table__th--highlight {
|
||||
color: #00f0ff;
|
||||
background: rgba(0, 18, 20, 0.97);
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.comparison-table__th--highlight::after {
|
||||
|
|
@ -624,6 +626,10 @@ function getStatusIcon(status: string): string {
|
|||
|
||||
/* Responsive */
|
||||
@media (max-width: 960px) {
|
||||
.comparison-section {
|
||||
--comparison-sticky-header-offset: 60px;
|
||||
}
|
||||
|
||||
.comparison-table__wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
|
@ -641,6 +647,12 @@ function getStatusIcon(status: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1600px) {
|
||||
.comparison-section {
|
||||
--comparison-sticky-header-offset: 124px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.comparison-section__title {
|
||||
font-size: 1.6rem;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
"main": "dist-electron/main/index.cjs",
|
||||
"scripts": {
|
||||
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||
"dev:mcp": "node ./scripts/dev-with-runtime.mjs --remoteDebuggingPort 9222",
|
||||
"dev:kill": "node bin/kill-dev.js",
|
||||
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
|
||||
"opencode:prove-semantic-gauntlet": "node ./scripts/prove-opencode-semantic-gauntlet.mjs",
|
||||
|
|
|
|||
|
|
@ -2,16 +2,31 @@ diff --git a/dist/index.js b/dist/index.js
|
|||
index c91ae9196280060974778cbb1164839d5610e7d0..a2dd82afe79d7d0a6640e983166b4b205686dae9 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -58,7 +58,13 @@ var FocusScope = React.forwardRef((props, forwardedRef) => {
|
||||
@@ -58,7 +58,28 @@ var FocusScope = React.forwardRef((props, forwardedRef) => {
|
||||
const onMountAutoFocus = (0, import_react_use_callback_ref.useCallbackRef)(onMountAutoFocusProp);
|
||||
const onUnmountAutoFocus = (0, import_react_use_callback_ref.useCallbackRef)(onUnmountAutoFocusProp);
|
||||
const lastFocusedElementRef = React.useRef(null);
|
||||
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node) => setContainer(node));
|
||||
+ const containerRef = React.useRef(null);
|
||||
+ const containerCleanupGenerationRef = React.useRef(0);
|
||||
+ const setContainerRef = React.useCallback((node) => {
|
||||
+ if (containerRef.current === node) return;
|
||||
+ containerRef.current = node;
|
||||
+ setContainer(node);
|
||||
+ const syncContainer = (nextContainer) => {
|
||||
+ if (containerRef.current === nextContainer) return;
|
||||
+ containerRef.current = nextContainer;
|
||||
+ setContainer(nextContainer);
|
||||
+ };
|
||||
+ containerCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = containerCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncContainer(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (containerCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncContainer(null);
|
||||
+ });
|
||||
+ }, []);
|
||||
+ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setContainerRef);
|
||||
const focusScope = React.useRef({
|
||||
|
|
@ -21,16 +36,31 @@ diff --git a/dist/index.mjs b/dist/index.mjs
|
|||
index e39d5c9105b3f8060d037bf5490843d20d1c859a..70781360acc81bff33c36b8ebd8d6b278df58450 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -22,7 +22,13 @@ var FocusScope = React.forwardRef((props, forwardedRef) => {
|
||||
@@ -22,7 +22,28 @@ var FocusScope = React.forwardRef((props, forwardedRef) => {
|
||||
const onMountAutoFocus = useCallbackRef(onMountAutoFocusProp);
|
||||
const onUnmountAutoFocus = useCallbackRef(onUnmountAutoFocusProp);
|
||||
const lastFocusedElementRef = React.useRef(null);
|
||||
- const composedRefs = useComposedRefs(forwardedRef, (node) => setContainer(node));
|
||||
+ const containerRef = React.useRef(null);
|
||||
+ const containerCleanupGenerationRef = React.useRef(0);
|
||||
+ const setContainerRef = React.useCallback((node) => {
|
||||
+ if (containerRef.current === node) return;
|
||||
+ containerRef.current = node;
|
||||
+ setContainer(node);
|
||||
+ const syncContainer = (nextContainer) => {
|
||||
+ if (containerRef.current === nextContainer) return;
|
||||
+ containerRef.current = nextContainer;
|
||||
+ setContainer(nextContainer);
|
||||
+ };
|
||||
+ containerCleanupGenerationRef.current += 1;
|
||||
+ const cleanupGeneration = containerCleanupGenerationRef.current;
|
||||
+ if (node) {
|
||||
+ syncContainer(node);
|
||||
+ return;
|
||||
+ }
|
||||
+ queueMicrotask(() => {
|
||||
+ if (containerCleanupGenerationRef.current !== cleanupGeneration) {
|
||||
+ return;
|
||||
+ }
|
||||
+ syncContainer(null);
|
||||
+ });
|
||||
+ }, []);
|
||||
+ const composedRefs = useComposedRefs(forwardedRef, setContainerRef);
|
||||
const focusScope = React.useRef({
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ overrides:
|
|||
|
||||
patchedDependencies:
|
||||
'@radix-ui/react-focus-scope@1.1.7':
|
||||
hash: 7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d
|
||||
hash: cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829
|
||||
path: patches/@radix-ui__react-focus-scope@1.1.7.patch
|
||||
'@radix-ui/react-presence@1.1.5':
|
||||
hash: afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e
|
||||
|
|
@ -13992,7 +13992,7 @@ snapshots:
|
|||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-presence': 1.1.5(patch_hash=afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -14047,7 +14047,7 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-focus-scope@1.1.7(patch_hash=7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
'@radix-ui/react-focus-scope@1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -14100,7 +14100,7 @@ snapshots:
|
|||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -14124,7 +14124,7 @@ snapshots:
|
|||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
@ -14223,7 +14223,7 @@ snapshots:
|
|||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(patch_hash=cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
|
|
|
|||
|
|
@ -754,6 +754,35 @@
|
|||
"supports_native_structured_output": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"jp.anthropic.claude-sonnet-4-6": {
|
||||
"cache_creation_input_token_cost": 0.000004125,
|
||||
"cache_read_input_token_cost": 3.3e-7,
|
||||
"input_cost_per_token": 0.0000033,
|
||||
"litellm_provider": "bedrock_converse",
|
||||
"max_input_tokens": 1000000,
|
||||
"max_output_tokens": 64000,
|
||||
"max_tokens": 64000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 0.0000165,
|
||||
"search_context_cost_per_query": {
|
||||
"search_context_size_high": 0.01,
|
||||
"search_context_size_low": 0.01,
|
||||
"search_context_size_medium": 0.01
|
||||
},
|
||||
"supports_assistant_prefill": true,
|
||||
"supports_computer_use": true,
|
||||
"supports_function_calling": true,
|
||||
"supports_pdf_input": true,
|
||||
"supports_prompt_caching": true,
|
||||
"supports_reasoning": true,
|
||||
"supports_response_schema": true,
|
||||
"supports_max_reasoning_effort": true,
|
||||
"supports_tool_choice": true,
|
||||
"supports_vision": true,
|
||||
"tool_use_system_prompt_tokens": 346,
|
||||
"supports_native_structured_output": true,
|
||||
"supports_minimal_reasoning_effort": true
|
||||
},
|
||||
"anthropic.claude-sonnet-4-20250514-v1:0": {
|
||||
"cache_creation_input_token_cost": 0.00000375,
|
||||
"cache_read_input_token_cost": 3e-7,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,9 @@ const defaultRuntimeCacheRoot = path.join(os.homedir(), '.agent-teams', 'runtime
|
|||
const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim()
|
||||
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
|
||||
: defaultRuntimeCacheRoot;
|
||||
const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path');
|
||||
const scriptArgs = process.argv.slice(2);
|
||||
const shouldPrintRuntimePath = scriptArgs.includes('--print-runtime-path');
|
||||
const electronViteArgs = scriptArgs.filter((arg) => arg !== '--print-runtime-path' && arg !== '--');
|
||||
const runtimeDisplayName = 'teams orchestrator';
|
||||
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
|
||||
|
||||
|
|
@ -542,7 +544,7 @@ async function main() {
|
|||
delete uiEnv.CLAUDE_CLI_PATH;
|
||||
const uiPackageManager = readPackageManagerCommand(uiRepoRoot);
|
||||
|
||||
runOrExit(uiPackageManager, ['exec', 'electron-vite', 'dev'], {
|
||||
runOrExit(uiPackageManager, ['exec', 'electron-vite', 'dev', ...electronViteArgs], {
|
||||
cwd: uiRepoRoot,
|
||||
env: uiEnv,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,9 +28,9 @@ import type {
|
|||
const LOG_PREVIEW_FALLBACK_WIDTH = 260;
|
||||
const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
|
||||
const NEW_LOG_HIGHLIGHT_MS = 1_000;
|
||||
const COMPACT_ROW_TITLE_LIMIT = 24;
|
||||
const COMPACT_ROW_TEXT_LIMIT = 76;
|
||||
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 40;
|
||||
const COMPACT_ROW_TITLE_LIMIT = 28;
|
||||
const COMPACT_ROW_TEXT_LIMIT = 160;
|
||||
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 96;
|
||||
const INTERACTIVE_LOG_CONTROL_CLASS = 'pointer-events-auto';
|
||||
|
||||
interface StableRectLike {
|
||||
|
|
@ -82,7 +82,7 @@ function formatRelativeTime(timestamp: string): string {
|
|||
}
|
||||
|
||||
function itemIcon(item: MemberLogPreviewItem): React.JSX.Element {
|
||||
const className = 'size-3.5 shrink-0';
|
||||
const className = 'size-3 shrink-0';
|
||||
const title = item.title.trim().toLowerCase();
|
||||
if (item.tone === 'error') {
|
||||
return <AlertCircle className={`${className} text-rose-300`} />;
|
||||
|
|
@ -254,10 +254,10 @@ function renderLoadingSkeleton(): React.JSX.Element {
|
|||
{[0, 1, 2].map((index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="flex h-[72px] min-h-[72px] w-full min-w-0 animate-pulse rounded-md border border-white/10 bg-[rgba(8,14,28,0.42)] px-2.5 py-1.5"
|
||||
className="grid h-[72px] min-h-[72px] w-full min-w-0 grid-cols-[1rem_minmax(0,1fr)] gap-x-1.5 overflow-hidden rounded-md border border-white/10 bg-[rgba(8,14,28,0.42)] px-2 py-1.5"
|
||||
>
|
||||
<span className="mr-2 mt-0.5 inline-flex size-5 shrink-0 rounded bg-white/10" />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-2 pt-0.5">
|
||||
<span className="mt-0.5 inline-flex size-4 shrink-0 rounded bg-white/10" />
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1 pt-0.5">
|
||||
<span className="h-3 w-2/5 rounded bg-slate-400/20" />
|
||||
<span className="h-2.5 w-full rounded bg-slate-400/15" />
|
||||
<span className="h-2.5 w-2/3 rounded bg-slate-400/10" />
|
||||
|
|
@ -527,35 +527,35 @@ export const GraphMemberLogPreviewHud = ({
|
|||
? 'border-rose-400/35 bg-rose-950/20 hover:border-rose-300/50 hover:bg-rose-950/30'
|
||||
: 'border-white/10 bg-[rgba(8,14,28,0.52)] hover:border-white/20 hover:bg-[rgba(12,20,40,0.78)]';
|
||||
const iconClassName = isError
|
||||
? 'float-left mr-2 mt-0 inline-flex size-5 shrink-0 items-center justify-center rounded bg-rose-500/10'
|
||||
: 'float-left mr-2 mt-0 inline-flex size-5 shrink-0 items-center justify-center rounded bg-white/5';
|
||||
const headerClassName = 'inline align-baseline';
|
||||
? 'inline-flex size-4 shrink-0 items-center justify-center rounded bg-rose-500/10'
|
||||
: 'inline-flex size-4 shrink-0 items-center justify-center rounded bg-white/5';
|
||||
const headerClassName = 'flex h-4 min-w-0 items-center gap-1.5';
|
||||
const titleClassName = isError
|
||||
? 'align-baseline text-[11px] font-medium leading-5 text-rose-100'
|
||||
: 'align-baseline text-[11px] font-medium leading-5 text-slate-200';
|
||||
? 'min-w-0 truncate text-[10.5px] font-medium leading-4 text-rose-100'
|
||||
: 'min-w-0 truncate text-[10.5px] font-medium leading-4 text-slate-200';
|
||||
const timeClassName = isError
|
||||
? 'ml-1 align-baseline text-[9px] font-normal leading-5 text-rose-300/70'
|
||||
: 'ml-1 align-baseline text-[9px] font-normal leading-5 text-slate-500';
|
||||
? 'shrink-0 text-[9px] font-normal leading-4 text-rose-300/70'
|
||||
: 'shrink-0 text-[9px] font-normal leading-4 text-slate-500';
|
||||
const previewClassName = isError
|
||||
? 'ml-1 break-words align-baseline text-[10px] leading-5 text-rose-100/85'
|
||||
: 'ml-1 break-words align-baseline text-[10px] leading-5 text-slate-300/85';
|
||||
? 'mt-1 line-clamp-2 min-w-0 break-words text-[10px] leading-[15px] text-rose-100/85'
|
||||
: 'mt-1 line-clamp-2 min-w-0 break-words text-[10px] leading-[15px] text-slate-300/85';
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={[
|
||||
`${INTERACTIVE_LOG_CONTROL_CLASS} block h-[72px] min-h-[72px] w-full min-w-0 overflow-hidden rounded-md border px-2.5 py-1 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500`,
|
||||
`${INTERACTIVE_LOG_CONTROL_CLASS} flex h-[72px] min-h-[72px] w-full min-w-0 flex-col overflow-hidden rounded-md border px-2 py-1.5 text-left text-slate-400 transition-[border-color,background-color,box-shadow] duration-500`,
|
||||
rowStateClassName,
|
||||
].join(' ')}
|
||||
title={titleText}
|
||||
aria-label={titleText}
|
||||
onClick={() => openLogs(memberName)}
|
||||
>
|
||||
<span className={iconClassName} aria-hidden="true">
|
||||
{itemIcon(item)}
|
||||
</span>
|
||||
<span className={headerClassName}>
|
||||
<span className={iconClassName} aria-hidden="true">
|
||||
{itemIcon(item)}
|
||||
</span>
|
||||
<span className={titleClassName}>{displayTitle}</span>
|
||||
{relativeTime ? <span className={timeClassName}>{relativeTime}</span> : null}
|
||||
</span>
|
||||
|
|
@ -605,8 +605,8 @@ export const GraphMemberLogPreviewHud = ({
|
|||
}}
|
||||
>
|
||||
<div className="flex h-full min-w-0 max-w-full flex-col overflow-hidden">
|
||||
<div className="flex h-5 min-h-5 items-center gap-1 px-1 text-[10px] font-semibold tracking-[0.2em] text-slate-400/70">
|
||||
<Wrench className="size-3 text-slate-500" />
|
||||
<div className="flex h-4 min-h-4 items-center gap-1 px-1 text-[9px] font-semibold tracking-[0.18em] text-slate-400/70">
|
||||
<Wrench className="size-2.5 text-slate-500" />
|
||||
Logs
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from '@shared/utils/effortLevels';
|
||||
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isTeamProviderBackendId, migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { constants as fsConstants } from 'fs';
|
||||
import { access } from 'fs/promises';
|
||||
|
|
@ -33,6 +33,9 @@ type CreateTeamBody = TeamCreateConfigRequest;
|
|||
class HttpBadRequestError extends Error {}
|
||||
class HttpFeatureUnavailableError extends Error {}
|
||||
|
||||
const PROVIDER_BACKEND_ERROR =
|
||||
'providerBackendId must be valid for the selected provider (auto, adapter, api, cli-sdk, codex-native, or opencode-cli)';
|
||||
|
||||
function isMemberWorkSyncReportState(value: string): value is MemberWorkSyncReportState {
|
||||
return value === 'still_working' || value === 'blocked' || value === 'caught_up';
|
||||
}
|
||||
|
|
@ -212,14 +215,30 @@ function parseProviderBackendId(
|
|||
const rawProviderBackendId = assertOptionalString(value, 'providerBackendId');
|
||||
const providerBackendId = migrateProviderBackendId(providerId, rawProviderBackendId);
|
||||
if (rawProviderBackendId && !providerBackendId) {
|
||||
throw new HttpBadRequestError(
|
||||
'providerBackendId must be one of auto, adapter, api, cli-sdk, or codex-native'
|
||||
);
|
||||
throw new HttpBadRequestError(PROVIDER_BACKEND_ERROR);
|
||||
}
|
||||
return providerBackendId;
|
||||
}
|
||||
|
||||
function parseCreateMembers(payloadMembers: unknown): TeamCreateConfigRequest['members'] {
|
||||
function parseLaunchProviderBackendId(
|
||||
providerId: TeamLaunchRequest['providerId'],
|
||||
value: unknown
|
||||
): TeamLaunchRequest['providerBackendId'] | undefined {
|
||||
const rawProviderBackendId = assertOptionalString(value, 'providerBackendId');
|
||||
const providerBackendId = migrateProviderBackendId(providerId, rawProviderBackendId);
|
||||
if (providerBackendId || !rawProviderBackendId) {
|
||||
return providerBackendId;
|
||||
}
|
||||
if (isTeamProviderBackendId(rawProviderBackendId)) {
|
||||
return undefined;
|
||||
}
|
||||
throw new HttpBadRequestError(PROVIDER_BACKEND_ERROR);
|
||||
}
|
||||
|
||||
function parseCreateMembers(
|
||||
payloadMembers: unknown,
|
||||
defaultProviderId: TeamLaunchRequest['providerId']
|
||||
): TeamCreateConfigRequest['members'] {
|
||||
if (payloadMembers == null) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -250,9 +269,12 @@ function parseCreateMembers(payloadMembers: unknown): TeamCreateConfigRequest['m
|
|||
}
|
||||
const providerId =
|
||||
rawMember.providerId == null ? undefined : parseProviderId(rawMember.providerId);
|
||||
const providerBackendId = parseProviderBackendId(providerId, rawMember.providerBackendId);
|
||||
const providerBackendId = parseProviderBackendId(
|
||||
providerId ?? defaultProviderId,
|
||||
rawMember.providerBackendId
|
||||
);
|
||||
const model = assertOptionalString(rawMember.model, 'member model');
|
||||
const effort = assertOptionalEffort(rawMember.effort, providerId);
|
||||
const effort = assertOptionalEffort(rawMember.effort, providerId ?? defaultProviderId);
|
||||
const fastMode = assertOptionalFastMode(rawMember.fastMode);
|
||||
|
||||
return {
|
||||
|
|
@ -273,9 +295,9 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
|
|||
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
||||
const providerId = parseProviderId(payload.providerId);
|
||||
const prompt = assertOptionalString(payload.prompt, 'prompt');
|
||||
const providerBackendId = parseProviderBackendId(providerId, payload.providerBackendId);
|
||||
const providerBackendId = parseLaunchProviderBackendId(providerId, payload.providerBackendId);
|
||||
const model = assertOptionalString(payload.model, 'model');
|
||||
const effort = assertOptionalEffort(payload.effort, providerId);
|
||||
const effort = assertOptionalEffort(payload.effort, providerId ?? 'anthropic');
|
||||
const fastMode = assertOptionalFastMode(payload.fastMode);
|
||||
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
|
||||
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
|
||||
|
|
@ -320,14 +342,14 @@ function parseCreateTeamRequest(body: unknown): TeamCreateConfigRequest {
|
|||
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
||||
const teamName = assertProvisioningTeamName(payload.teamName);
|
||||
const providerId = payload.providerId == null ? undefined : parseProviderId(payload.providerId);
|
||||
const providerBackendId = parseProviderBackendId(providerId, payload.providerBackendId);
|
||||
const providerBackendId = parseLaunchProviderBackendId(providerId, payload.providerBackendId);
|
||||
const displayName = assertOptionalString(payload.displayName, 'displayName');
|
||||
const description = assertOptionalString(payload.description, 'description');
|
||||
const color = assertOptionalString(payload.color, 'color');
|
||||
const cwd = assertOptionalCwd(payload.cwd);
|
||||
const prompt = assertOptionalString(payload.prompt, 'prompt');
|
||||
const model = assertOptionalString(payload.model, 'model');
|
||||
const effort = assertOptionalEffort(payload.effort, providerId);
|
||||
const effort = assertOptionalEffort(payload.effort, providerId ?? 'anthropic');
|
||||
const fastMode = assertOptionalFastMode(payload.fastMode);
|
||||
const limitContext = assertOptionalBoolean(payload.limitContext, 'limitContext');
|
||||
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
|
||||
|
|
@ -336,7 +358,7 @@ function parseCreateTeamRequest(body: unknown): TeamCreateConfigRequest {
|
|||
|
||||
return {
|
||||
teamName,
|
||||
members: parseCreateMembers(payload.members),
|
||||
members: parseCreateMembers(payload.members, providerId ?? 'anthropic'),
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(color ? { color } : {}),
|
||||
|
|
@ -389,19 +411,29 @@ function parseDraftLaunchCreateRequest(
|
|||
const providerId = Object.hasOwn(payload, 'providerId')
|
||||
? parseProviderId(payload.providerId)
|
||||
: (savedRequest.providerId ?? 'anthropic');
|
||||
const providerBackendId = parseProviderBackendId(
|
||||
const providerChangedFromSaved =
|
||||
Object.hasOwn(payload, 'providerId') && providerId !== (savedRequest.providerId ?? 'anthropic');
|
||||
const providerBackendId = parseLaunchProviderBackendId(
|
||||
providerId,
|
||||
Object.hasOwn(payload, 'providerBackendId')
|
||||
? payload.providerBackendId
|
||||
: savedRequest.providerBackendId
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.providerBackendId
|
||||
);
|
||||
const effort = assertOptionalEffort(
|
||||
Object.hasOwn(payload, 'effort') ? payload.effort : savedRequest.effort,
|
||||
Object.hasOwn(payload, 'effort')
|
||||
? payload.effort
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.effort,
|
||||
providerId
|
||||
);
|
||||
const fastMode = Object.hasOwn(payload, 'fastMode')
|
||||
? assertOptionalFastMode(payload.fastMode)
|
||||
: savedRequest.fastMode;
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.fastMode;
|
||||
const extraCliArgs = Object.hasOwn(payload, 'extraCliArgs')
|
||||
? assertOptionalExtraCliArgs(payload.extraCliArgs)
|
||||
: savedRequest.extraCliArgs;
|
||||
|
|
@ -419,13 +451,18 @@ function parseDraftLaunchCreateRequest(
|
|||
prompt: pickOptionalString(payload, 'prompt', savedRequest.prompt, 'prompt'),
|
||||
providerId,
|
||||
...(providerBackendId ? { providerBackendId } : {}),
|
||||
model: pickOptionalString(payload, 'model', savedRequest.model, 'model'),
|
||||
model: pickOptionalString(
|
||||
payload,
|
||||
'model',
|
||||
providerChangedFromSaved ? undefined : savedRequest.model,
|
||||
'model'
|
||||
),
|
||||
...(effort ? { effort } : {}),
|
||||
...(fastMode ? { fastMode } : {}),
|
||||
limitContext: pickOptionalBoolean(
|
||||
payload,
|
||||
'limitContext',
|
||||
savedRequest.limitContext,
|
||||
providerChangedFromSaved ? undefined : savedRequest.limitContext,
|
||||
'limitContext'
|
||||
),
|
||||
skipPermissions: pickOptionalBoolean(
|
||||
|
|
|
|||
|
|
@ -1467,7 +1467,42 @@ function parseOptionalProviderBackendId(
|
|||
|
||||
return {
|
||||
valid: false,
|
||||
error: 'providerBackendId must be one of auto, adapter, api, cli-sdk, or codex-native',
|
||||
error:
|
||||
'providerBackendId must be valid for the selected provider (auto, adapter, api, cli-sdk, codex-native, or opencode-cli)',
|
||||
};
|
||||
}
|
||||
|
||||
function parseOptionalLaunchProviderBackendId(
|
||||
value: unknown,
|
||||
providerId?: TeamProviderId
|
||||
): { valid: true; value: TeamProviderBackendId | undefined } | { valid: false; error: string } {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
return { valid: false, error: 'providerBackendId must be a string' };
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (trimmed.length > 64) {
|
||||
return { valid: false, error: 'providerBackendId too long (max 64)' };
|
||||
}
|
||||
|
||||
const migratedBackendId = migrateProviderBackendId(providerId, trimmed);
|
||||
if (migratedBackendId) {
|
||||
return { valid: true, value: migratedBackendId };
|
||||
}
|
||||
|
||||
if (isTeamProviderBackendId(trimmed)) {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
'providerBackendId must be valid for the selected provider (auto, adapter, api, cli-sdk, codex-native, or opencode-cli)',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1775,15 +1810,11 @@ async function validateProvisioningRequest(
|
|||
if (!Array.isArray(payload.members)) {
|
||||
return { valid: false, error: 'members must be an array' };
|
||||
}
|
||||
const explicitProviderId =
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: payload.providerId === 'anthropic'
|
||||
? 'anthropic'
|
||||
: undefined;
|
||||
const providerId = explicitProviderId ?? 'anthropic';
|
||||
const providerValidation = parseOptionalTeamProviderId(payload.providerId);
|
||||
if (!providerValidation.valid) {
|
||||
return { valid: false, error: providerValidation.error };
|
||||
}
|
||||
const providerId = providerValidation.value ?? 'anthropic';
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const members: TeamCreateRequest['members'] = [];
|
||||
|
|
@ -1821,7 +1852,7 @@ async function validateProvisioningRequest(
|
|||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
(member as { providerBackendId?: unknown }).providerBackendId,
|
||||
providerValidation.value
|
||||
providerValidation.value ?? providerId
|
||||
);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { valid: false, error: providerBackendValidation.error };
|
||||
|
|
@ -1867,7 +1898,7 @@ async function validateProvisioningRequest(
|
|||
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
|
||||
return { valid: false, error: 'prompt must be a string' };
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
const providerBackendValidation = parseOptionalLaunchProviderBackendId(
|
||||
payload.providerBackendId,
|
||||
providerId
|
||||
);
|
||||
|
|
@ -2076,16 +2107,13 @@ async function handleLaunchTeam(
|
|||
if (payload.model !== undefined && typeof payload.model !== 'string') {
|
||||
return { success: false, error: 'model must be a string' };
|
||||
}
|
||||
const explicitProviderId =
|
||||
payload.providerId === 'codex'
|
||||
? 'codex'
|
||||
: payload.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: payload.providerId === 'anthropic'
|
||||
? 'anthropic'
|
||||
: undefined;
|
||||
const providerValidation = parseOptionalTeamProviderId(payload.providerId);
|
||||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
const explicitProviderId = providerValidation.value;
|
||||
const providerId = explicitProviderId ?? 'anthropic';
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
const providerBackendValidation = parseOptionalLaunchProviderBackendId(
|
||||
payload.providerBackendId,
|
||||
providerId
|
||||
);
|
||||
|
|
@ -2113,20 +2141,44 @@ async function handleLaunchTeam(
|
|||
return { success: false, error: `Missing saved request for draft team: ${tn}` };
|
||||
}
|
||||
|
||||
const savedProviderId = savedRequest.providerId ?? 'anthropic';
|
||||
const resolvedProviderId = explicitProviderId ?? savedRequest.providerId ?? providerId;
|
||||
const providerChangedFromSaved =
|
||||
explicitProviderId != null && explicitProviderId !== savedProviderId;
|
||||
const effortValidation = parseOptionalTeamEffort(
|
||||
Object.hasOwn(payload, 'effort') ? payload.effort : savedRequest.effort,
|
||||
Object.hasOwn(payload, 'effort')
|
||||
? payload.effort
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.effort,
|
||||
resolvedProviderId
|
||||
);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
const fastModeValidation = parseOptionalTeamFastMode(
|
||||
Object.hasOwn(payload, 'fastMode') ? payload.fastMode : savedRequest.fastMode
|
||||
Object.hasOwn(payload, 'fastMode')
|
||||
? payload.fastMode
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.fastMode
|
||||
);
|
||||
if (!fastModeValidation.valid) {
|
||||
return { success: false, error: fastModeValidation.error };
|
||||
}
|
||||
const draftModel = Object.hasOwn(payload, 'model')
|
||||
? typeof payload.model === 'string'
|
||||
? payload.model.trim() || undefined
|
||||
: undefined
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.model;
|
||||
const draftLimitContext =
|
||||
typeof payload.limitContext === 'boolean'
|
||||
? payload.limitContext
|
||||
: providerChangedFromSaved
|
||||
? undefined
|
||||
: savedRequest.limitContext;
|
||||
|
||||
const createRequest: TeamCreateRequest = {
|
||||
teamName: tn,
|
||||
|
|
@ -2143,14 +2195,10 @@ async function handleLaunchTeam(
|
|||
resolvedProviderId,
|
||||
providerBackendValidation.value ?? savedRequest.providerBackendId
|
||||
),
|
||||
model:
|
||||
typeof payload.model === 'string' ? payload.model.trim() || undefined : savedRequest.model,
|
||||
model: draftModel,
|
||||
effort: effortValidation.value,
|
||||
fastMode: fastModeValidation.value,
|
||||
limitContext:
|
||||
typeof payload.limitContext === 'boolean'
|
||||
? payload.limitContext
|
||||
: savedRequest.limitContext,
|
||||
limitContext: draftLimitContext,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean'
|
||||
? payload.skipPermissions
|
||||
|
|
@ -2186,39 +2234,64 @@ async function handleLaunchTeam(
|
|||
}
|
||||
|
||||
const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null);
|
||||
const launchProviderId = explicitProviderId ?? persistedMeta?.providerId ?? providerId;
|
||||
const rawLaunchProviderBackendId =
|
||||
payload.providerBackendId ??
|
||||
persistedMeta?.providerBackendId ??
|
||||
persistedMeta?.launchIdentity?.providerBackendId ??
|
||||
undefined;
|
||||
const launchProviderBackendValidation = parseOptionalProviderBackendId(
|
||||
const persistedLaunchProviderId =
|
||||
persistedMeta?.launchIdentity?.providerId ?? persistedMeta?.providerId ?? 'anthropic';
|
||||
const launchProviderId =
|
||||
explicitProviderId ??
|
||||
persistedMeta?.launchIdentity?.providerId ??
|
||||
persistedMeta?.providerId ??
|
||||
providerId;
|
||||
const providerChangedFromPersisted =
|
||||
explicitProviderId != null && explicitProviderId !== persistedLaunchProviderId;
|
||||
const rawLaunchProviderBackendId = Object.hasOwn(payload, 'providerBackendId')
|
||||
? payload.providerBackendId
|
||||
: providerChangedFromPersisted
|
||||
? undefined
|
||||
: persistedMeta?.launchIdentity
|
||||
? migrateProviderBackendId(
|
||||
persistedMeta.launchIdentity.providerId,
|
||||
persistedMeta.launchIdentity.providerBackendId ?? persistedMeta.providerBackendId
|
||||
)
|
||||
: (persistedMeta?.providerBackendId ?? undefined);
|
||||
const launchProviderBackendValidation = parseOptionalLaunchProviderBackendId(
|
||||
rawLaunchProviderBackendId,
|
||||
launchProviderId
|
||||
);
|
||||
if (!launchProviderBackendValidation.valid) {
|
||||
return { success: false, error: launchProviderBackendValidation.error };
|
||||
}
|
||||
const rawLaunchEffort = Object.hasOwn(payload, 'effort')
|
||||
? payload.effort
|
||||
: (persistedMeta?.effort ?? persistedMeta?.launchIdentity?.selectedEffort ?? undefined);
|
||||
const persistedLaunchEffort = providerChangedFromPersisted
|
||||
? undefined
|
||||
: (persistedMeta?.launchIdentity?.selectedEffort ?? persistedMeta?.effort ?? undefined);
|
||||
const rawLaunchEffort = Object.hasOwn(payload, 'effort') ? payload.effort : persistedLaunchEffort;
|
||||
const effortValidation = parseOptionalTeamEffort(rawLaunchEffort, launchProviderId);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
const persistedLaunchFastMode = providerChangedFromPersisted
|
||||
? undefined
|
||||
: (persistedMeta?.launchIdentity?.selectedFastMode ?? persistedMeta?.fastMode ?? undefined);
|
||||
const rawLaunchFastMode = Object.hasOwn(payload, 'fastMode')
|
||||
? payload.fastMode
|
||||
: (persistedMeta?.fastMode ?? persistedMeta?.launchIdentity?.selectedFastMode ?? undefined);
|
||||
: persistedLaunchFastMode;
|
||||
const fastModeValidation = parseOptionalTeamFastMode(rawLaunchFastMode);
|
||||
if (!fastModeValidation.valid) {
|
||||
return { success: false, error: fastModeValidation.error };
|
||||
}
|
||||
const rawLaunchModel =
|
||||
typeof payload.model === 'string' && payload.model.trim().length > 0
|
||||
const persistedLaunchModel = providerChangedFromPersisted
|
||||
? undefined
|
||||
: (persistedMeta?.launchIdentity?.selectedModel ?? persistedMeta?.model ?? undefined);
|
||||
const rawLaunchModel = Object.hasOwn(payload, 'model')
|
||||
? typeof payload.model === 'string' && payload.model.trim().length > 0
|
||||
? payload.model.trim()
|
||||
: (persistedMeta?.model ?? persistedMeta?.launchIdentity?.selectedModel ?? undefined);
|
||||
: undefined
|
||||
: persistedLaunchModel;
|
||||
const launchLimitContext =
|
||||
typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext;
|
||||
typeof payload.limitContext === 'boolean'
|
||||
? payload.limitContext
|
||||
: providerChangedFromPersisted
|
||||
? undefined
|
||||
: persistedMeta?.limitContext;
|
||||
|
||||
return wrapTeamHandler('launch', async () => {
|
||||
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
|
||||
|
|
@ -3573,13 +3646,14 @@ async function handleCreateConfig(
|
|||
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
|
||||
return { success: false, error: 'prompt must be a string' };
|
||||
}
|
||||
const providerValidation = parseOptionalTeamProviderId(payload.providerId);
|
||||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
const teamProviderValidation = parseOptionalTeamProviderId(payload.providerId);
|
||||
if (!teamProviderValidation.valid) {
|
||||
return { success: false, error: teamProviderValidation.error };
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
const effectiveTeamProviderId = teamProviderValidation.value ?? 'anthropic';
|
||||
const providerBackendValidation = parseOptionalLaunchProviderBackendId(
|
||||
payload.providerBackendId,
|
||||
providerValidation.value
|
||||
effectiveTeamProviderId
|
||||
);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { success: false, error: providerBackendValidation.error };
|
||||
|
|
@ -3587,7 +3661,7 @@ async function handleCreateConfig(
|
|||
if (payload.model !== undefined && typeof payload.model !== 'string') {
|
||||
return { success: false, error: 'model must be a string' };
|
||||
}
|
||||
const effortValidation = parseOptionalTeamEffort(payload.effort, providerValidation.value);
|
||||
const effortValidation = parseOptionalTeamEffort(payload.effort, effectiveTeamProviderId);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
|
|
@ -3668,9 +3742,10 @@ async function handleCreateConfig(
|
|||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
const effectiveMemberProviderId = providerValidation.value ?? effectiveTeamProviderId;
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
(member as { providerBackendId?: unknown }).providerBackendId,
|
||||
providerValidation.value
|
||||
effectiveMemberProviderId
|
||||
);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { success: false, error: providerBackendValidation.error };
|
||||
|
|
@ -3681,7 +3756,7 @@ async function handleCreateConfig(
|
|||
}
|
||||
const effortValidation = parseOptionalMemberEffort(
|
||||
(member as { effort?: unknown }).effort,
|
||||
providerValidation.value
|
||||
effectiveMemberProviderId
|
||||
);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
|
|
@ -3714,7 +3789,7 @@ async function handleCreateConfig(
|
|||
members,
|
||||
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId: providerValidation.value,
|
||||
providerId: teamProviderValidation.value,
|
||||
providerBackendId: providerBackendValidation.value,
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,15 @@
|
|||
|
||||
import { countContentTokens } from '@main/utils/tokenizer';
|
||||
|
||||
import type { AIChunk, EnhancedAIChunk, SemanticStep } from '@main/types';
|
||||
import type { AIChunk, ContentBlock, EnhancedAIChunk, SemanticStep } from '@main/types';
|
||||
|
||||
function normalizeAssistantContent(content: ContentBlock[] | string): ContentBlock[] {
|
||||
if (typeof content === 'string') {
|
||||
return content ? [{ type: 'text', text: content }] : [];
|
||||
}
|
||||
|
||||
return Array.isArray(content) ? content : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract semantic steps from AI chunk responses.
|
||||
|
|
@ -33,7 +41,7 @@ export function extractSemanticStepsFromAIChunk(chunk: AIChunk | EnhancedAIChunk
|
|||
for (const msg of chunk.responses) {
|
||||
if (msg.type === 'assistant') {
|
||||
// Extract from content blocks
|
||||
const content = Array.isArray(msg.content) ? msg.content : [];
|
||||
const content = normalizeAssistantContent(msg.content);
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type === 'thinking' && block.thinking) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import path from 'node:path';
|
|||
|
||||
import { resolveVerifiedAppManagedCodexRuntimeBinaryPath } from '@features/codex-runtime-installer/main';
|
||||
import { execCli } from '@main/utils/childProcess';
|
||||
import { buildEnrichedEnv } from '@main/utils/cliEnv';
|
||||
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
|
||||
import { getCachedShellEnv } from '@main/utils/shellEnv';
|
||||
|
||||
const CACHE_VERIFY_TTL_MS = 30_000;
|
||||
|
|
@ -27,6 +29,7 @@ async function fileExists(filePath: string): Promise<boolean> {
|
|||
async function binaryCanLaunch(candidate: string): Promise<boolean> {
|
||||
try {
|
||||
await execCli(candidate, ['--version'], {
|
||||
env: buildEnrichedEnv(candidate),
|
||||
timeout: BINARY_LAUNCH_VERIFY_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
|
@ -69,7 +72,7 @@ function getPathEntries(): string[] {
|
|||
const delimiter = process.platform === 'win32' ? ';' : path.delimiter;
|
||||
const shellEnv = getCachedShellEnv() ?? {};
|
||||
const seen = new Set<string>();
|
||||
return [shellEnv.PATH, process.env.PATH]
|
||||
return [shellEnv.PATH, buildMergedCliPath(null), process.env.PATH]
|
||||
.flatMap((pathValue) => (pathValue ?? '').split(delimiter))
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => {
|
||||
|
|
@ -193,6 +196,7 @@ export class CodexBinaryResolver {
|
|||
|
||||
try {
|
||||
const result = await execCli(normalizedPath, ['--version'], {
|
||||
env: buildEnrichedEnv(normalizedPath),
|
||||
timeout: 3_000,
|
||||
});
|
||||
const version = result.stdout.trim().split(/\s+/).filter(Boolean).at(-1) ?? null;
|
||||
|
|
|
|||
|
|
@ -8,14 +8,27 @@ import type { PathLike } from 'node:fs';
|
|||
|
||||
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
|
||||
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn<() => Promise<string | null>>();
|
||||
const execCliMock =
|
||||
vi.fn<
|
||||
(
|
||||
binaryPath: string | null,
|
||||
args: string[],
|
||||
options?: { timeout?: number; windowsHide?: boolean }
|
||||
) => Promise<{ stdout: string; stderr: string }>
|
||||
>();
|
||||
const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>(() => null);
|
||||
const buildEnrichedEnvMock = vi.fn(
|
||||
(binaryPath?: string | null): NodeJS.ProcessEnv => ({
|
||||
PATH: `enriched:${binaryPath ?? ''}`,
|
||||
CODEX_RESOLVER_TEST_BINARY: binaryPath ?? '',
|
||||
})
|
||||
);
|
||||
const buildMergedCliPathMock = vi.fn(
|
||||
(_binaryPath?: string | null): string => process.env.PATH ?? ''
|
||||
);
|
||||
const execCliMock = vi.fn<
|
||||
(
|
||||
binaryPath: string | null,
|
||||
args: string[],
|
||||
options?: {
|
||||
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
||||
timeout?: number;
|
||||
windowsHide?: boolean;
|
||||
}
|
||||
) => Promise<{ stdout: string; stderr: string }>
|
||||
>();
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
|
||||
|
|
@ -30,10 +43,26 @@ vi.mock('@main/utils/childProcess', () => ({
|
|||
execCli: (
|
||||
binaryPath: string | null,
|
||||
args: string[],
|
||||
options?: { timeout?: number; windowsHide?: boolean }
|
||||
options?: {
|
||||
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
|
||||
timeout?: number;
|
||||
windowsHide?: boolean;
|
||||
}
|
||||
) => execCliMock(binaryPath, args, options),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/cliEnv', () => ({
|
||||
buildEnrichedEnv: (binaryPath?: string | null) => buildEnrichedEnvMock(binaryPath),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/cliPathMerge', () => ({
|
||||
buildMergedCliPath: (binaryPath?: string | null) => buildMergedCliPathMock(binaryPath),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/shellEnv', () => ({
|
||||
getCachedShellEnv: () => getCachedShellEnvMock(),
|
||||
}));
|
||||
|
||||
const originalPlatform = process.platform;
|
||||
const originalPath = process.env.PATH;
|
||||
const originalPathExt = process.env.PATHEXT;
|
||||
|
|
@ -54,6 +83,8 @@ describe('CodexBinaryResolver', () => {
|
|||
setPlatform('win32');
|
||||
process.env.PATHEXT = '.EXE;.CMD;.BAT;.COM';
|
||||
delete process.env.CODEX_CLI_PATH;
|
||||
getCachedShellEnvMock.mockReturnValue(null);
|
||||
buildMergedCliPathMock.mockImplementation(() => process.env.PATH ?? '');
|
||||
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null);
|
||||
execCliMock.mockResolvedValue({ stdout: 'codex-cli 0.130.0', stderr: '' });
|
||||
});
|
||||
|
|
@ -170,4 +201,93 @@ describe('CodexBinaryResolver', () => {
|
|||
|
||||
await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim);
|
||||
});
|
||||
|
||||
it('verifies POSIX Codex npm shims with enriched env in packaged-like shells', async () => {
|
||||
setPlatform('darwin');
|
||||
process.env.PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
|
||||
const shellPath = '/usr/local/bin:/usr/bin:/bin';
|
||||
const codexShim = path.posix.join('/usr/local/bin', 'codex');
|
||||
getCachedShellEnvMock.mockReturnValue({
|
||||
HOME: '/Users/tester',
|
||||
PATH: shellPath,
|
||||
});
|
||||
|
||||
accessMock.mockImplementation((filePath) => {
|
||||
if (filePath === codexShim) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
});
|
||||
execCliMock.mockImplementation((_binaryPath, _args, options) => {
|
||||
if (options?.env?.PATH !== `enriched:${codexShim}`) {
|
||||
return Promise.reject(
|
||||
Object.assign(new Error('env: node: No such file or directory'), {
|
||||
code: 'ENOENT',
|
||||
})
|
||||
);
|
||||
}
|
||||
return Promise.resolve({ stdout: 'codex-cli 0.130.0', stderr: '' });
|
||||
});
|
||||
|
||||
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
|
||||
CodexBinaryResolver.clearCache();
|
||||
|
||||
await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim);
|
||||
expect(buildEnrichedEnvMock).toHaveBeenCalledWith(codexShim);
|
||||
expect(execCliMock).toHaveBeenCalledWith(
|
||||
codexShim,
|
||||
['--version'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CODEX_RESOLVER_TEST_BINARY: codexShim,
|
||||
PATH: `enriched:${codexShim}`,
|
||||
}),
|
||||
timeout: 3_000,
|
||||
windowsHide: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('finds POSIX Codex in merged fallback PATH when shell env is cold', async () => {
|
||||
setPlatform('darwin');
|
||||
process.env.PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
|
||||
const codexShim = path.posix.join('/usr/local/bin', 'codex');
|
||||
buildMergedCliPathMock.mockReturnValue('/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin');
|
||||
|
||||
accessMock.mockImplementation((filePath) => {
|
||||
if (filePath === codexShim) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
});
|
||||
|
||||
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
|
||||
CodexBinaryResolver.clearCache();
|
||||
|
||||
await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim);
|
||||
expect(buildMergedCliPathMock).toHaveBeenCalledWith(null);
|
||||
expect(buildEnrichedEnvMock).toHaveBeenCalledWith(codexShim);
|
||||
});
|
||||
|
||||
it('uses enriched env for Codex version probes', async () => {
|
||||
setPlatform('darwin');
|
||||
const codexShim = path.posix.join('/usr/local/bin', 'codex');
|
||||
|
||||
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
|
||||
CodexBinaryResolver.clearCache();
|
||||
|
||||
await expect(CodexBinaryResolver.resolveVersion(codexShim)).resolves.toBe('0.130.0');
|
||||
expect(buildEnrichedEnvMock).toHaveBeenCalledWith(codexShim);
|
||||
expect(execCliMock).toHaveBeenCalledWith(
|
||||
codexShim,
|
||||
['--version'],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CODEX_RESOLVER_TEST_BINARY: codexShim,
|
||||
PATH: `enriched:${codexShim}`,
|
||||
}),
|
||||
timeout: 3_000,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
|||
import type {
|
||||
AgentChangeSet,
|
||||
ChangeStats,
|
||||
TaskChangeReviewability,
|
||||
TaskChangeSetV2,
|
||||
TeamConfig,
|
||||
TeamTaskChangeSummariesResponse,
|
||||
|
|
@ -64,6 +65,39 @@ const TEAM_TASK_CHANGE_SUMMARY_BATCH_CONCURRENCY = 3;
|
|||
const TEAM_TASK_CHANGE_SUMMARY_TASK_TIMEOUT_MS = 15_000;
|
||||
const TEAM_TASK_CHANGE_SUMMARY_BATCH_TIMEOUT_MS = 30_000;
|
||||
|
||||
function shouldClearStaleTaskChangePresence(input: {
|
||||
result: TaskChangeSetV2;
|
||||
taskMeta: TaskChangeTaskMeta;
|
||||
effectiveOptions: TaskChangeEffectiveOptions;
|
||||
reviewability: TaskChangeReviewability;
|
||||
}): boolean {
|
||||
if (input.reviewability !== 'unknown') {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(input.result.files) || !Array.isArray(input.result.warnings)) {
|
||||
return false;
|
||||
}
|
||||
if (input.result.files.length > 0 || input.result.warnings.length > 0) {
|
||||
return false;
|
||||
}
|
||||
const status = getTaskMetaPreferredStatus(input.taskMeta, input.effectiveOptions);
|
||||
return (
|
||||
getTaskChangeStateBucket({
|
||||
status,
|
||||
reviewState: input.taskMeta.reviewState,
|
||||
historyEvents: input.taskMeta.historyEvents,
|
||||
kanbanColumn: input.taskMeta.kanbanColumn,
|
||||
}) === 'active'
|
||||
);
|
||||
}
|
||||
|
||||
function getTaskMetaPreferredStatus(
|
||||
taskMeta: TaskChangeTaskMeta | null,
|
||||
effectiveOptions: TaskChangeEffectiveOptions
|
||||
): string | undefined {
|
||||
return taskMeta?.status?.trim() || effectiveOptions.status?.trim() || undefined;
|
||||
}
|
||||
|
||||
/** Кеш-запись: данные + mtime файла + время протухания */
|
||||
interface CacheEntry {
|
||||
data: AgentChangeSet;
|
||||
|
|
@ -208,14 +242,18 @@ export class ChangeExtractorService {
|
|||
const taskMeta = await this.readTaskMeta(teamName, taskId);
|
||||
const effectiveOptions: TaskChangeEffectiveOptions = {
|
||||
owner: options?.owner ?? taskMeta?.owner,
|
||||
status: options?.status ?? taskMeta?.status,
|
||||
status: taskMeta?.status?.trim() || options?.status?.trim(),
|
||||
intervals: options?.intervals ?? taskMeta?.intervals,
|
||||
since: options?.since,
|
||||
};
|
||||
const projectPath = await this.resolveProjectPath(teamName);
|
||||
const cacheOptions: TaskChangeEffectiveOptions = {
|
||||
...effectiveOptions,
|
||||
status: getTaskMetaPreferredStatus(taskMeta, effectiveOptions),
|
||||
};
|
||||
const effectiveStateBucket = taskMeta
|
||||
? getTaskChangeStateBucket({
|
||||
status: effectiveOptions.status,
|
||||
status: cacheOptions.status,
|
||||
reviewState: taskMeta.reviewState,
|
||||
historyEvents: taskMeta.historyEvents,
|
||||
kanbanColumn: taskMeta.kanbanColumn,
|
||||
|
|
@ -279,7 +317,7 @@ export class ChangeExtractorService {
|
|||
const cacheKey = this.buildTaskChangeSummaryCacheKey(
|
||||
teamName,
|
||||
taskId,
|
||||
effectiveOptions,
|
||||
cacheOptions,
|
||||
effectiveStateBucket,
|
||||
version
|
||||
);
|
||||
|
|
@ -306,7 +344,7 @@ export class ChangeExtractorService {
|
|||
const persisted = await this.readPersistedTaskChangeSummary(
|
||||
teamName,
|
||||
taskId,
|
||||
effectiveOptions,
|
||||
cacheOptions,
|
||||
effectiveStateBucket,
|
||||
taskMeta
|
||||
);
|
||||
|
|
@ -333,7 +371,7 @@ export class ChangeExtractorService {
|
|||
await this.persistTaskChangeSummary(
|
||||
teamName,
|
||||
taskId,
|
||||
effectiveOptions,
|
||||
cacheOptions,
|
||||
effectiveStateBucket,
|
||||
result,
|
||||
version
|
||||
|
|
@ -1422,7 +1460,7 @@ export class ChangeExtractorService {
|
|||
}
|
||||
|
||||
const currentBucket = getTaskChangeStateBucket({
|
||||
status: taskMeta.status ?? effectiveOptions.status,
|
||||
status: getTaskMetaPreferredStatus(taskMeta, effectiveOptions),
|
||||
reviewState: taskMeta.reviewState,
|
||||
historyEvents: taskMeta.historyEvents,
|
||||
kanbanColumn: taskMeta.kanbanColumn,
|
||||
|
|
@ -1464,7 +1502,7 @@ export class ChangeExtractorService {
|
|||
const currentTaskMeta = await this.readTaskMeta(teamName, taskId);
|
||||
if (!currentTaskMeta) return;
|
||||
const currentBucket = getTaskChangeStateBucket({
|
||||
status: currentTaskMeta.status ?? effectiveOptions.status,
|
||||
status: getTaskMetaPreferredStatus(currentTaskMeta, effectiveOptions),
|
||||
reviewState: currentTaskMeta.reviewState,
|
||||
historyEvents: currentTaskMeta.historyEvents,
|
||||
kanbanColumn: currentTaskMeta.kanbanColumn,
|
||||
|
|
@ -1546,7 +1584,15 @@ export class ChangeExtractorService {
|
|||
const reviewability = classifyTaskChangeReviewability(result);
|
||||
const resolvedPresence = resolveTaskChangePresenceFromResult(result);
|
||||
if (!resolvedPresence) {
|
||||
if (reviewability.reviewability === 'diagnostic_only') {
|
||||
if (
|
||||
reviewability.reviewability === 'diagnostic_only' ||
|
||||
shouldClearStaleTaskChangePresence({
|
||||
result,
|
||||
taskMeta,
|
||||
effectiveOptions,
|
||||
reviewability: reviewability.reviewability,
|
||||
})
|
||||
) {
|
||||
await this.taskChangePresenceRepository.deleteEntry?.(teamName, taskId);
|
||||
}
|
||||
return;
|
||||
|
|
@ -1555,7 +1601,7 @@ export class ChangeExtractorService {
|
|||
const descriptor = buildTaskChangePresenceDescriptor({
|
||||
createdAt: taskMeta.createdAt,
|
||||
owner: effectiveOptions.owner ?? taskMeta.owner,
|
||||
status: effectiveOptions.status ?? taskMeta.status,
|
||||
status: getTaskMetaPreferredStatus(taskMeta, effectiveOptions),
|
||||
intervals: effectiveOptions.intervals ?? taskMeta.intervals,
|
||||
since: effectiveOptions.since,
|
||||
reviewState: taskMeta.reviewState,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getTaskChangeStateBucket } from '@shared/utils/taskChangeState';
|
||||
import { createReadStream } from 'fs';
|
||||
import { stat } from 'fs/promises';
|
||||
import * as readline from 'readline';
|
||||
|
|
@ -20,6 +21,7 @@ import type {
|
|||
} from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TaskChangeComputer');
|
||||
const NO_LOG_FILES_FOUND_WARNING = 'No log files found for this task.';
|
||||
|
||||
interface ParsedSnippetsCacheEntry {
|
||||
data: ParsedSnippetRecord[];
|
||||
|
|
@ -51,6 +53,17 @@ interface ParsedJsonlEntry {
|
|||
lineNumber: number;
|
||||
}
|
||||
|
||||
function shouldWarnAboutMissingTaskLogs(input: ResolvedTaskChangeComputeInput): boolean {
|
||||
const status = input.taskMeta?.status?.trim() || input.effectiveOptions.status?.trim();
|
||||
const stateBucket = getTaskChangeStateBucket({
|
||||
status,
|
||||
reviewState: input.taskMeta?.reviewState,
|
||||
historyEvents: input.taskMeta?.historyEvents,
|
||||
kanbanColumn: input.taskMeta?.kanbanColumn,
|
||||
});
|
||||
return stateBucket === 'completed' || stateBucket === 'review' || stateBucket === 'approved';
|
||||
}
|
||||
|
||||
export class TaskChangeComputer {
|
||||
private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
|
||||
private parsedSnippetsInFlight = new Map<string, Promise<ParsedSnippetsResult>>();
|
||||
|
|
@ -102,7 +115,7 @@ export class TaskChangeComputer {
|
|||
effectiveOptions
|
||||
);
|
||||
if (logRefs.length === 0) {
|
||||
return this.emptyTaskChangeSet(teamName, taskId);
|
||||
return this.emptyTaskChangeSet(input);
|
||||
}
|
||||
|
||||
const allScopes: TaskChangeScope[] = [];
|
||||
|
|
@ -441,7 +454,8 @@ export class TaskChangeComputer {
|
|||
};
|
||||
}
|
||||
|
||||
private emptyTaskChangeSet(teamName: string, taskId: string): TaskChangeSetV2 {
|
||||
private emptyTaskChangeSet(input: ResolvedTaskChangeComputeInput): TaskChangeSetV2 {
|
||||
const { teamName, taskId } = input;
|
||||
return {
|
||||
teamName,
|
||||
taskId,
|
||||
|
|
@ -462,7 +476,7 @@ export class TaskChangeComputer {
|
|||
filePaths: [],
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
|
||||
},
|
||||
warnings: ['No log files found for this task.'],
|
||||
warnings: shouldWarnAboutMissingTaskLogs(input) ? [NO_LOG_FILES_FOUND_WARNING] : [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -557,6 +557,12 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
const launchIdentity = teamMeta?.launchIdentity;
|
||||
const providerBackendId = launchIdentity
|
||||
? (migrateProviderBackendId(
|
||||
launchIdentity.providerId,
|
||||
launchIdentity.providerBackendId ?? teamMeta?.providerBackendId
|
||||
) ?? undefined)
|
||||
: (migrateProviderBackendId(teamMeta?.providerId, teamMeta?.providerBackendId) ?? undefined);
|
||||
const leadName = 'team-lead';
|
||||
const ownedTasks = tasks.filter((task) => task.owner === leadName);
|
||||
const currentTask = selectCurrentActiveTeamTask(ownedTasks);
|
||||
|
|
@ -572,10 +578,7 @@ export class TeamDataService {
|
|||
workflow: undefined,
|
||||
isolation: undefined,
|
||||
providerId: launchIdentity?.providerId ?? teamMeta?.providerId,
|
||||
providerBackendId:
|
||||
launchIdentity?.providerBackendId ??
|
||||
migrateProviderBackendId(teamMeta?.providerId, teamMeta?.providerBackendId) ??
|
||||
undefined,
|
||||
providerBackendId,
|
||||
model:
|
||||
launchIdentity?.resolvedLaunchModel ?? launchIdentity?.selectedModel ?? teamMeta?.model,
|
||||
effort:
|
||||
|
|
@ -1382,6 +1385,14 @@ export class TeamDataService {
|
|||
: tasksWithKanbanBase;
|
||||
mark('changePresence');
|
||||
|
||||
const launchIdentity = teamMeta?.launchIdentity;
|
||||
const leadProviderBackendId = launchIdentity
|
||||
? (migrateProviderBackendId(
|
||||
launchIdentity.providerId,
|
||||
launchIdentity.providerBackendId ?? teamMeta?.providerBackendId
|
||||
) ?? undefined)
|
||||
: (migrateProviderBackendId(teamMeta?.providerId, teamMeta?.providerBackendId) ?? undefined);
|
||||
|
||||
const members = this.memberResolver.resolveMembers(
|
||||
config,
|
||||
metaMembers,
|
||||
|
|
@ -1389,11 +1400,8 @@ export class TeamDataService {
|
|||
tasksWithKanban,
|
||||
{
|
||||
launchSnapshot,
|
||||
leadProviderId: teamMeta?.launchIdentity?.providerId ?? teamMeta?.providerId,
|
||||
leadProviderBackendId:
|
||||
teamMeta?.launchIdentity?.providerBackendId ??
|
||||
migrateProviderBackendId(teamMeta?.providerId, teamMeta?.providerBackendId) ??
|
||||
undefined,
|
||||
leadProviderId: launchIdentity?.providerId ?? teamMeta?.providerId,
|
||||
leadProviderBackendId,
|
||||
leadFastMode: teamMeta?.launchIdentity?.selectedFastMode ?? teamMeta?.fastMode ?? undefined,
|
||||
leadResolvedFastMode:
|
||||
typeof teamMeta?.launchIdentity?.resolvedFastMode === 'boolean'
|
||||
|
|
|
|||
|
|
@ -300,13 +300,15 @@ export class TeamMemberResolver {
|
|||
providerId: effectiveProviderId,
|
||||
},
|
||||
});
|
||||
const providerBackendId =
|
||||
const providerBackendId = migrateProviderBackendId(
|
||||
effectiveProviderId,
|
||||
launchMember?.providerBackendId ??
|
||||
configMember?.providerBackendId ??
|
||||
metaMember?.providerBackendId ??
|
||||
(effectiveProviderId === options?.leadProviderId
|
||||
? (options?.leadProviderBackendId ?? undefined)
|
||||
: undefined);
|
||||
configMember?.providerBackendId ??
|
||||
metaMember?.providerBackendId ??
|
||||
(effectiveProviderId === options?.leadProviderId
|
||||
? (options?.leadProviderBackendId ?? undefined)
|
||||
: undefined)
|
||||
);
|
||||
const agentId = configMember?.agentId ?? metaMember?.agentId;
|
||||
members.push({
|
||||
name,
|
||||
|
|
|
|||
|
|
@ -3668,17 +3668,14 @@ function buildEffectiveTeamMemberSpec(
|
|||
const memberProviderId = normalizeTeamMemberProviderId(member.providerId);
|
||||
const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId);
|
||||
const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic';
|
||||
const explicitMemberModel = getExplicitLaunchModelSelection(member.model);
|
||||
const inheritsDefaultRuntime = memberProviderId == null || memberProviderId === defaultProviderId;
|
||||
const model =
|
||||
getExplicitLaunchModelSelection(member.model) ||
|
||||
(memberProviderId == null || memberProviderId === defaultProviderId
|
||||
? getExplicitLaunchModelSelection(defaults.model)
|
||||
: undefined) ||
|
||||
explicitMemberModel ||
|
||||
(inheritsDefaultRuntime ? getExplicitLaunchModelSelection(defaults.model) : undefined) ||
|
||||
undefined;
|
||||
const effort =
|
||||
member.effort ??
|
||||
(memberProviderId == null || memberProviderId === defaultProviderId
|
||||
? defaults.effort
|
||||
: undefined);
|
||||
member.effort ?? (inheritsDefaultRuntime && !explicitMemberModel ? defaults.effort : undefined);
|
||||
|
||||
return {
|
||||
...member,
|
||||
|
|
@ -15173,6 +15170,16 @@ export class TeamProvisioningService {
|
|||
return fallback;
|
||||
};
|
||||
|
||||
const activeRunMemberByName = new Map<string, TeamMember>();
|
||||
const runAllEffectiveMembers = run?.allEffectiveMembers ?? [];
|
||||
const activeRunMembers =
|
||||
runAllEffectiveMembers.length > 0 ? runAllEffectiveMembers : (run?.effectiveMembers ?? []);
|
||||
for (const member of activeRunMembers) {
|
||||
const memberName = typeof member?.name === 'string' ? member.name.trim() : '';
|
||||
if (!memberName) continue;
|
||||
activeRunMemberByName.set(memberName, member);
|
||||
}
|
||||
|
||||
const candidateMembers = new Map<string, TeamMember>();
|
||||
for (const member of configuredMembers) {
|
||||
const memberName = typeof member?.name === 'string' ? member.name.trim() : '';
|
||||
|
|
@ -15201,6 +15208,11 @@ export class TeamProvisioningService {
|
|||
fastMode: launchMember?.selectedFastMode,
|
||||
});
|
||||
}
|
||||
for (const member of activeRunMemberByName.values()) {
|
||||
const memberName = typeof member?.name === 'string' ? member.name.trim() : '';
|
||||
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) continue;
|
||||
candidateMembers.set(memberName, member);
|
||||
}
|
||||
|
||||
for (const member of candidateMembers.values()) {
|
||||
const memberName = typeof member?.name === 'string' ? member.name.trim() : '';
|
||||
|
|
@ -15234,22 +15246,52 @@ export class TeamProvisioningService {
|
|||
const liveRuntimeMember = getLiveRuntimeMember(memberName);
|
||||
const spawnStatusMember = getSpawnStatusMember(memberName);
|
||||
const launchMember = launchSnapshot?.members[memberName];
|
||||
const activeRunMember = activeRunMemberByName.get(memberName);
|
||||
const activeRunModel = activeRunMember?.model?.trim();
|
||||
const activeRunProviderId =
|
||||
normalizeOptionalTeamProviderId(activeRunMember?.providerId) ??
|
||||
inferTeamProviderIdFromModel(activeRunModel);
|
||||
const liveRuntimeModel = liveRuntimeMember?.model?.trim();
|
||||
const liveRuntimeModelProviderId = inferTeamProviderIdFromModel(liveRuntimeModel);
|
||||
const explicitLiveRuntimeProviderId = normalizeOptionalTeamProviderId(
|
||||
liveRuntimeMember?.providerId
|
||||
);
|
||||
const liveRuntimeProviderConflictsWithActive =
|
||||
activeRunProviderId != null &&
|
||||
((explicitLiveRuntimeProviderId != null &&
|
||||
explicitLiveRuntimeProviderId !== activeRunProviderId) ||
|
||||
(liveRuntimeModelProviderId != null &&
|
||||
liveRuntimeModelProviderId !== activeRunProviderId));
|
||||
const canUseLiveRuntimeModel = !!liveRuntimeModel && !liveRuntimeProviderConflictsWithActive;
|
||||
const backendType =
|
||||
liveRuntimeMember?.backendType ??
|
||||
normalizeTeamAgentRuntimeBackendType(persistedRuntimeMember?.backendType, false);
|
||||
const runtimeModel =
|
||||
liveRuntimeMember?.model ??
|
||||
(canUseLiveRuntimeModel ? liveRuntimeModel : undefined) ??
|
||||
activeRunModel ??
|
||||
launchMember?.model?.trim() ??
|
||||
member.model?.trim() ??
|
||||
undefined;
|
||||
const memberProviderId =
|
||||
launchMember?.providerId ??
|
||||
activeRunProviderId ??
|
||||
normalizeOptionalTeamProviderId(launchMember?.providerId) ??
|
||||
normalizeOptionalTeamProviderId(member.providerId) ??
|
||||
inferTeamProviderIdFromModel(runtimeModel) ??
|
||||
inferTeamProviderIdFromModel(launchMember?.model) ??
|
||||
inferTeamProviderIdFromModel(member.model);
|
||||
const memberProviderBackendId = migrateProviderBackendId(
|
||||
memberProviderId,
|
||||
activeRunMember?.providerBackendId ??
|
||||
launchMember?.providerBackendId ??
|
||||
member.providerBackendId
|
||||
);
|
||||
const isOpenCodeMember = memberProviderId === 'opencode';
|
||||
const configuredCwd = typeof member.cwd === 'string' ? member.cwd.trim() : '';
|
||||
const configuredCwd =
|
||||
typeof activeRunMember?.cwd === 'string'
|
||||
? activeRunMember.cwd.trim()
|
||||
: typeof member.cwd === 'string'
|
||||
? member.cwd.trim()
|
||||
: '';
|
||||
const runtimeCwd =
|
||||
liveRuntimeMember?.cwd ??
|
||||
(configuredCwd || (isOpenCodeMember ? currentRuntimeAdapterRun?.cwd : undefined));
|
||||
|
|
@ -15319,9 +15361,7 @@ export class TeamProvisioningService {
|
|||
restartable,
|
||||
...(backendType ? { backendType } : {}),
|
||||
...(memberProviderId ? { providerId: memberProviderId } : {}),
|
||||
...(launchMember?.providerBackendId
|
||||
? { providerBackendId: launchMember.providerBackendId }
|
||||
: {}),
|
||||
...(memberProviderBackendId ? { providerBackendId: memberProviderBackendId } : {}),
|
||||
...(launchMember?.laneId ? { laneId: launchMember.laneId } : {}),
|
||||
...(launchMember?.laneKind ? { laneKind: launchMember.laneKind } : {}),
|
||||
...(displayPid ? { pid: displayPid } : {}),
|
||||
|
|
@ -15355,15 +15395,25 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
const persistedLaunchIdentity = persistedTeamMeta?.launchIdentity;
|
||||
const snapshotProviderId =
|
||||
run?.request.providerId ??
|
||||
persistedLaunchIdentity?.providerId ??
|
||||
persistedTeamMeta?.providerId;
|
||||
const snapshotProviderBackendId = run
|
||||
? run.request.providerBackendId
|
||||
: persistedLaunchIdentity
|
||||
? (persistedLaunchIdentity.providerBackendId ?? persistedTeamMeta?.providerBackendId)
|
||||
: persistedTeamMeta?.providerBackendId;
|
||||
const snapshot: TeamAgentRuntimeSnapshot = {
|
||||
teamName,
|
||||
updatedAt,
|
||||
runId: run?.runId ?? runId,
|
||||
providerBackendId: migrateProviderBackendId(
|
||||
run?.request.providerId ?? persistedTeamMeta?.providerId,
|
||||
run?.request.providerBackendId ?? persistedTeamMeta?.providerBackendId
|
||||
),
|
||||
fastMode: run?.request.fastMode ?? persistedTeamMeta?.fastMode,
|
||||
providerBackendId: migrateProviderBackendId(snapshotProviderId, snapshotProviderBackendId),
|
||||
fastMode:
|
||||
run?.request.fastMode ??
|
||||
persistedLaunchIdentity?.selectedFastMode ??
|
||||
persistedTeamMeta?.fastMode,
|
||||
members: snapshotMembers,
|
||||
};
|
||||
|
||||
|
|
@ -25017,18 +25067,24 @@ export class TeamProvisioningService {
|
|||
run: ProvisioningRun | null,
|
||||
memberName: string
|
||||
): string | undefined {
|
||||
const member = this.findEffectiveRunMember(run, memberName);
|
||||
const model = member?.model?.trim();
|
||||
return model || undefined;
|
||||
}
|
||||
|
||||
private findEffectiveRunMember(
|
||||
run: ProvisioningRun | null,
|
||||
memberName: string
|
||||
): TeamCreateRequest['members'][number] | undefined {
|
||||
if (!run) {
|
||||
return undefined;
|
||||
}
|
||||
for (const member of run.effectiveMembers ?? []) {
|
||||
for (const member of [...(run.allEffectiveMembers ?? []), ...(run.effectiveMembers ?? [])]) {
|
||||
const candidateName = member.name?.trim() ?? '';
|
||||
if (!candidateName || !matchesTeamMemberIdentity(candidateName, memberName)) {
|
||||
continue;
|
||||
}
|
||||
const model = member.model?.trim();
|
||||
if (model) {
|
||||
return model;
|
||||
}
|
||||
return member;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -25204,8 +25260,8 @@ export class TeamProvisioningService {
|
|||
continue;
|
||||
}
|
||||
const runtimeModel =
|
||||
this.findConfiguredMemberModel(configuredMembers, memberName) ??
|
||||
this.findEffectiveRunMemberModel(run, memberName) ??
|
||||
this.findConfiguredMemberModel(configuredMembers, memberName) ??
|
||||
this.findMetaMemberModel(metaMembers, memberName);
|
||||
upsertMetadata(memberName, {
|
||||
backendType: normalizeTeamAgentRuntimeBackendType(member.backendType, false),
|
||||
|
|
@ -25248,8 +25304,8 @@ export class TeamProvisioningService {
|
|||
? configuredRuntimeMember.backendType
|
||||
: undefined;
|
||||
const runtimeModel =
|
||||
member.model?.trim() ||
|
||||
this.findEffectiveRunMemberModel(run, memberName) ||
|
||||
member.model?.trim() ||
|
||||
this.findMetaMemberModel(metaMembers, memberName);
|
||||
upsertMetadata(memberName, {
|
||||
...(runtimeModel ? { model: runtimeModel } : {}),
|
||||
|
|
@ -25277,9 +25333,9 @@ export class TeamProvisioningService {
|
|||
continue;
|
||||
}
|
||||
const runtimeModel =
|
||||
this.findEffectiveRunMemberModel(run, memberName) ||
|
||||
member.model?.trim() ||
|
||||
this.findConfiguredMemberModel(configuredMembers, memberName) ||
|
||||
this.findEffectiveRunMemberModel(run, memberName);
|
||||
this.findConfiguredMemberModel(configuredMembers, memberName);
|
||||
upsertMetadata(memberName, {
|
||||
...(runtimeModel ? { model: runtimeModel } : {}),
|
||||
...(normalizeOptionalTeamProviderId(member.providerId)
|
||||
|
|
@ -25297,8 +25353,10 @@ export class TeamProvisioningService {
|
|||
if (!memberName || isLeadMember(member) || memberName.toLowerCase() === 'user') {
|
||||
continue;
|
||||
}
|
||||
const providerId = normalizeOptionalTeamProviderId(member.providerId);
|
||||
upsertMetadata(memberName, {
|
||||
...(member.model?.trim() ? { model: member.model.trim() } : {}),
|
||||
...(providerId ? { providerId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -25342,13 +25400,19 @@ export class TeamProvisioningService {
|
|||
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
|
||||
continue;
|
||||
}
|
||||
const activeRunMember = this.findEffectiveRunMember(run, memberName);
|
||||
const activeRunModel = activeRunMember?.model?.trim();
|
||||
const activeRunProviderId =
|
||||
normalizeOptionalTeamProviderId(activeRunMember?.providerId) ??
|
||||
inferTeamProviderIdFromModel(activeRunModel);
|
||||
const effectiveProviderId = activeRunProviderId ?? persistedMember.providerId;
|
||||
const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
||||
upsertMetadata(memberName, {
|
||||
backendType:
|
||||
persistedMember.providerId === 'opencode'
|
||||
effectiveProviderId === 'opencode'
|
||||
? 'process'
|
||||
: metadataByMember.get(memberName)?.backendType,
|
||||
providerId: persistedMember.providerId,
|
||||
providerId: effectiveProviderId,
|
||||
alive: false,
|
||||
livenessKind: currentRuntimeAdapterEvidence?.livenessKind ?? persistedMember.livenessKind,
|
||||
pidSource: currentRuntimeAdapterEvidence?.pidSource ?? persistedMember.pidSource,
|
||||
|
|
@ -25359,7 +25423,11 @@ export class TeamProvisioningService {
|
|||
persistedMember.runtimeLastSeenAt ??
|
||||
persistedMember.lastHeartbeatAt ??
|
||||
persistedMember.lastRuntimeAliveAt,
|
||||
...(persistedMember.model?.trim() ? { model: persistedMember.model.trim() } : {}),
|
||||
...(activeRunModel
|
||||
? { model: activeRunModel }
|
||||
: persistedMember.model?.trim()
|
||||
? { model: persistedMember.model.trim() }
|
||||
: {}),
|
||||
...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' &&
|
||||
currentRuntimeAdapterEvidence.runtimePid > 0
|
||||
? { metricsPid: currentRuntimeAdapterEvidence.runtimePid }
|
||||
|
|
@ -27758,7 +27826,10 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const teamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null);
|
||||
const leadProviderId = normalizeOptionalTeamProviderId(teamMeta?.providerId);
|
||||
const leadLaunchIdentity = teamMeta?.launchIdentity;
|
||||
const leadProviderId =
|
||||
normalizeOptionalTeamProviderId(leadLaunchIdentity?.providerId) ??
|
||||
normalizeOptionalTeamProviderId(teamMeta?.providerId);
|
||||
if (!leadProviderId || leadProviderId === 'opencode') {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -27793,9 +27864,13 @@ export class TeamProvisioningService {
|
|||
providerBackendId:
|
||||
migrateProviderBackendId(
|
||||
leadProviderId,
|
||||
teamMeta?.providerBackendId ?? membersMeta?.providerBackendId
|
||||
leadLaunchIdentity
|
||||
? (leadLaunchIdentity.providerBackendId ??
|
||||
teamMeta?.providerBackendId ??
|
||||
membersMeta?.providerBackendId)
|
||||
: (teamMeta?.providerBackendId ?? membersMeta?.providerBackendId)
|
||||
) ?? null,
|
||||
selectedFastMode: teamMeta?.fastMode,
|
||||
selectedFastMode: leadLaunchIdentity?.selectedFastMode ?? teamMeta?.fastMode,
|
||||
resolvedFastMode:
|
||||
typeof teamMeta?.launchIdentity?.resolvedFastMode === 'boolean'
|
||||
? teamMeta.launchIdentity.resolvedFastMode
|
||||
|
|
|
|||
|
|
@ -100,6 +100,13 @@ const SECRET_FLAG_PATTERN =
|
|||
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
|
||||
const BEARER_TOKEN_PATTERN = /\bBearer\s+\S+/gi;
|
||||
const SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g;
|
||||
const OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_WARNING =
|
||||
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried once.';
|
||||
const OPEN_CODE_CAPABILITY_SNAPSHOT_MISMATCH_MARKERS = [
|
||||
'Bridge server capability snapshot mismatch',
|
||||
'OpenCode bridge capability snapshot precondition mismatch',
|
||||
'OpenCode bridge capability snapshot mismatch',
|
||||
];
|
||||
|
||||
function resolveOpenCodeRuntimeSettlementMode(
|
||||
input: Pick<OpenCodeTeamRuntimeMessageInput, 'messageKind'>
|
||||
|
|
@ -184,7 +191,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
]);
|
||||
}
|
||||
|
||||
const runtimeSnapshot = skipReadinessPreflight
|
||||
let runtimeSnapshot = skipReadinessPreflight
|
||||
? null
|
||||
: (this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null);
|
||||
if (
|
||||
|
|
@ -197,23 +204,56 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
]);
|
||||
}
|
||||
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
|
||||
const data = await this.bridge.launchOpenCodeTeam({
|
||||
const buildLaunchCommand = (
|
||||
snapshot: OpenCodeBridgeRuntimeSnapshot | null,
|
||||
model: string
|
||||
): OpenCodeLaunchTeamCommandBody => ({
|
||||
runId: input.runId,
|
||||
laneId: input.laneId?.trim() || 'primary',
|
||||
teamId: input.teamName,
|
||||
teamName: input.teamName,
|
||||
projectPath: input.cwd,
|
||||
selectedModel,
|
||||
selectedModel: model,
|
||||
members: input.expectedMembers.map((member) => ({
|
||||
name: member.name,
|
||||
role: member.role?.trim() || member.workflow?.trim() || 'teammate',
|
||||
prompt: buildMemberBootstrapPrompt(input, member),
|
||||
})),
|
||||
leadPrompt: input.prompt?.trim() ?? '',
|
||||
expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null,
|
||||
expectedCapabilitySnapshotId: snapshot?.capabilitySnapshotId ?? null,
|
||||
manifestHighWatermark: null,
|
||||
});
|
||||
|
||||
let data = await this.bridge.launchOpenCodeTeam(
|
||||
buildLaunchCommand(runtimeSnapshot, selectedModel)
|
||||
);
|
||||
if (!skipReadinessPreflight && isOpenCodeCapabilitySnapshotMismatchLaunchData(data)) {
|
||||
const refreshed = await this.prepare(input);
|
||||
if (!refreshed.ok) {
|
||||
return blockedLaunchResult(
|
||||
input,
|
||||
refreshed.reason,
|
||||
mergeDiagnostics(data.diagnostics.map(formatOpenCodeBridgeDiagnostic), [
|
||||
OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_WARNING,
|
||||
...refreshed.diagnostics,
|
||||
]),
|
||||
mergeDiagnostics(launchWarnings, refreshed.warnings)
|
||||
);
|
||||
}
|
||||
selectedModel = refreshed.modelId ?? selectedModel;
|
||||
const refreshedSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null;
|
||||
if (refreshedSnapshot?.capabilitySnapshotId) {
|
||||
runtimeSnapshot = refreshedSnapshot;
|
||||
launchWarnings = mergeDiagnostics(launchWarnings, [
|
||||
...refreshed.warnings,
|
||||
OPEN_CODE_CAPABILITY_SNAPSHOT_REFRESH_RETRY_WARNING,
|
||||
]);
|
||||
data = await this.bridge.launchOpenCodeTeam(
|
||||
buildLaunchCommand(runtimeSnapshot, selectedModel)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return mapOpenCodeLaunchDataToRuntimeResult(input, data, launchWarnings);
|
||||
}
|
||||
|
||||
|
|
@ -1053,6 +1093,30 @@ function formatOpenCodeBridgeDiagnostic(diagnostic: {
|
|||
return `${diagnostic.severity}:${diagnostic.code}: ${diagnostic.message}`;
|
||||
}
|
||||
|
||||
function isOpenCodeCapabilitySnapshotMismatchLaunchData(
|
||||
data: OpenCodeLaunchTeamCommandData
|
||||
): boolean {
|
||||
if (
|
||||
data.diagnostics.some(
|
||||
(diagnostic) =>
|
||||
isOpenCodeCapabilitySnapshotMismatchText(diagnostic.message) ||
|
||||
isOpenCodeCapabilitySnapshotMismatchText(diagnostic.code)
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(data.members).some((member) =>
|
||||
(member.diagnostics ?? []).some(isOpenCodeCapabilitySnapshotMismatchText)
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenCodeCapabilitySnapshotMismatchText(value: string): boolean {
|
||||
const normalized = value.toLowerCase();
|
||||
return OPEN_CODE_CAPABILITY_SNAPSHOT_MISMATCH_MARKERS.some((marker) =>
|
||||
normalized.includes(marker.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenCodeLaunchTimingDiagnostic(diagnostic: string): boolean {
|
||||
return (
|
||||
diagnostic.startsWith('info:opencode_launch_member_timing:') ||
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ export function isParsedInternalUserMessage(msg: ParsedMessage): boolean {
|
|||
* - Interruption messages: [Request interrupted by user...]
|
||||
*
|
||||
* Filtered assistant messages:
|
||||
* - Synthetic messages with model='<synthetic>' (system-generated placeholders)
|
||||
* - Empty synthetic messages with model='<synthetic>' (system-generated placeholders)
|
||||
*/
|
||||
export function isParsedHardNoiseMessage(msg: ParsedMessage): boolean {
|
||||
// Filter structural metadata types - these should never be displayed
|
||||
|
|
@ -319,8 +319,13 @@ export function isParsedHardNoiseMessage(msg: ParsedMessage): boolean {
|
|||
if (msg.type === 'file-history-snapshot') return true;
|
||||
if (msg.type === 'queue-operation') return true;
|
||||
|
||||
// Filter synthetic assistant messages (system-generated placeholders)
|
||||
if (msg.type === 'assistant' && msg.model === '<synthetic>') {
|
||||
// Filter empty synthetic assistant placeholders, but keep Codex-native synthetic
|
||||
// entries that carry real text or tool calls for member/task logs.
|
||||
if (
|
||||
msg.type === 'assistant' &&
|
||||
msg.model === '<synthetic>' &&
|
||||
!hasRenderableAssistantContent(msg.content)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -368,6 +373,29 @@ export function isParsedHardNoiseMessage(msg: ParsedMessage): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function hasRenderableAssistantContent(content: ParsedMessage['content']): boolean {
|
||||
if (typeof content === 'string') {
|
||||
return content.trim().length > 0;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return content.some((block) => {
|
||||
if (block.type === 'text') {
|
||||
return block.text.trim().length > 0;
|
||||
}
|
||||
if (block.type === 'thinking') {
|
||||
return block.thinking.trim().length > 0;
|
||||
}
|
||||
if (block.type === 'tool_use') {
|
||||
return block.name.trim().length > 0;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect compact summary messages.
|
||||
* These are markers indicating conversation was compacted.
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ interface RawMember {
|
|||
name?: unknown;
|
||||
agentType?: unknown;
|
||||
role?: unknown;
|
||||
cwd?: unknown;
|
||||
color?: unknown;
|
||||
providerId?: unknown;
|
||||
provider?: unknown;
|
||||
|
|
@ -666,6 +667,59 @@ function isRawMember(v: unknown): v is RawMember {
|
|||
return !!v && typeof v === 'object';
|
||||
}
|
||||
|
||||
function normalizeProjectPathCandidate(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function getRawConfigMembers(config: Pick<ParsedConfig, 'members'>): RawMember[] {
|
||||
if (!Array.isArray(config.members)) {
|
||||
return [];
|
||||
}
|
||||
return config.members.filter(isRawMember);
|
||||
}
|
||||
|
||||
function resolveProjectPathFromConfig(
|
||||
config: Pick<ParsedConfig, 'projectPath' | 'projectPathHistory' | 'members'>
|
||||
): string | undefined {
|
||||
const direct = normalizeProjectPathCandidate(config.projectPath);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
|
||||
const members = getRawConfigMembers(config);
|
||||
const leadMemberCwd = members.find((member) => isLeadMember(member))?.cwd;
|
||||
const leadResolved = normalizeProjectPathCandidate(leadMemberCwd);
|
||||
if (leadResolved) {
|
||||
return leadResolved;
|
||||
}
|
||||
|
||||
const distinctMemberCwds = Array.from(
|
||||
new Set(
|
||||
members
|
||||
.map((member) => normalizeProjectPathCandidate(member.cwd))
|
||||
.filter((cwd): cwd is string => Boolean(cwd))
|
||||
)
|
||||
);
|
||||
if (distinctMemberCwds.length === 1) {
|
||||
return distinctMemberCwds[0];
|
||||
}
|
||||
|
||||
if (Array.isArray(config.projectPathHistory)) {
|
||||
for (let i = config.projectPathHistory.length - 1; i >= 0; i -= 1) {
|
||||
const historyValue = normalizeProjectPathCandidate(config.projectPathHistory[i]);
|
||||
if (historyValue) {
|
||||
return historyValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mergeMember(
|
||||
m: RawMember,
|
||||
memberMap: Map<string, { name: string; role?: string; color?: string }>,
|
||||
|
|
@ -981,10 +1035,7 @@ async function listTeams(
|
|||
typeof config.color === 'string' && config.color.trim().length > 0
|
||||
? config.color
|
||||
: undefined;
|
||||
projectPath =
|
||||
typeof config.projectPath === 'string' && config.projectPath.trim().length > 0
|
||||
? config.projectPath
|
||||
: undefined;
|
||||
projectPath = resolveProjectPathFromConfig(config);
|
||||
leadSessionId =
|
||||
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
|
||||
? config.leadSessionId
|
||||
|
|
|
|||
|
|
@ -741,6 +741,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
|
|||
<SidebarTaskItem
|
||||
task={task}
|
||||
hideTeamName
|
||||
hideProjectName
|
||||
renamingKey={renamingTaskKey}
|
||||
onRenameComplete={handleRenameComplete}
|
||||
onRenameCancel={handleRenameCancel}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
|||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { clearTaskManualUnread } from '@renderer/services/commentReadStorage';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
|
||||
|
|
@ -62,6 +63,7 @@ function formatUpdatedLabel(task: GlobalTask): string | null {
|
|||
interface SidebarTaskItemProps {
|
||||
task: GlobalTask;
|
||||
hideTeamName?: boolean;
|
||||
hideProjectName?: boolean;
|
||||
showTeamName?: boolean;
|
||||
/** The composite key "teamName:taskId" of the task being renamed, or null */
|
||||
renamingKey?: string | null;
|
||||
|
|
@ -76,6 +78,7 @@ interface SidebarTaskItemProps {
|
|||
export const SidebarTaskItem = memo(function SidebarTaskItem({
|
||||
task,
|
||||
hideTeamName,
|
||||
hideProjectName,
|
||||
showTeamName,
|
||||
renamingKey,
|
||||
onRenameComplete,
|
||||
|
|
@ -117,6 +120,11 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
|||
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
|
||||
: (statusConfig[task.status] ?? statusConfig.pending);
|
||||
const StatusIcon = cfg.icon;
|
||||
const statusIconClassName = cn(
|
||||
'size-3 shrink-0',
|
||||
cfg.color,
|
||||
cfg.label === 'in progress' && 'animate-spin'
|
||||
);
|
||||
const updatedLabel = formatUpdatedLabel(task);
|
||||
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
|
||||
|
||||
|
|
@ -133,9 +141,10 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
|||
}, [ownerColorSet, isLight]);
|
||||
|
||||
const projectLabel = useMemo(() => {
|
||||
if (hideProjectName) return null;
|
||||
if (!task.projectPath?.trim()) return null;
|
||||
return projectLabelFromPath(task.projectPath);
|
||||
}, [task.projectPath]);
|
||||
}, [hideProjectName, task.projectPath]);
|
||||
|
||||
const projectColorSet = useMemo(
|
||||
() => (projectLabel ? projectColor(projectLabel, isLight) : null),
|
||||
|
|
@ -167,7 +176,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
|||
<div className="w-full overflow-hidden">
|
||||
{isRenaming ? (
|
||||
<div className="flex items-start gap-1.5">
|
||||
<StatusIcon className={`mt-0.5 size-3 shrink-0 ${cfg.color}`} />
|
||||
<StatusIcon className={cn('mt-0.5', statusIconClassName)} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
|
|
@ -207,7 +216,9 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
|
|||
className="line-clamp-2 text-[13px] font-medium leading-tight"
|
||||
style={{ color: 'var(--color-text-muted)' }}
|
||||
>
|
||||
<StatusIcon className={`mr-1.5 inline-block size-3 align-[-1px] ${cfg.color}`} />
|
||||
<StatusIcon
|
||||
className={cn('mr-1.5 inline-block align-[-1px]', statusIconClassName)}
|
||||
/>
|
||||
{unreadCount > 0 &&
|
||||
(unreadCount === 1 ? (
|
||||
<span className="mr-1 inline-block size-1.5 rounded-full bg-blue-400 align-middle" />
|
||||
|
|
|
|||
|
|
@ -22,6 +22,19 @@ const PREVIEW_ICONS = {
|
|||
tool: <Wrench size={12} className="shrink-0" />,
|
||||
} as const;
|
||||
|
||||
const LogsHeaderSkeletonPill = ({
|
||||
className,
|
||||
}: Readonly<{ className?: string }>): React.JSX.Element => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'inline-flex animate-pulse rounded-full shadow-[inset_0_0_0_1px_rgba(148,163,184,0.08)]',
|
||||
className
|
||||
)}
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--color-text-muted) 30%, transparent)' }}
|
||||
/>
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Sub-components
|
||||
// =============================================================================
|
||||
|
|
@ -79,6 +92,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
|||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
|
||||
const isSidebar = position === 'sidebar';
|
||||
const showHeaderSkeleton = ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error;
|
||||
|
||||
const sectionHeaderExtra = useMemo(
|
||||
() => (
|
||||
|
|
@ -90,11 +104,46 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
|||
</span>
|
||||
) : null}
|
||||
{ctrl.lastLogPreview ? <LogPreviewInline preview={ctrl.lastLogPreview} /> : null}
|
||||
{showHeaderSkeleton ? (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 opacity-70">
|
||||
<LogsHeaderSkeletonPill className="size-3 rounded" />
|
||||
<LogsHeaderSkeletonPill className="h-3 w-12 rounded" />
|
||||
<LogsHeaderSkeletonPill className="h-3 w-2 rounded" />
|
||||
<LogsHeaderSkeletonPill className="h-3 min-w-0 flex-1 rounded" />
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
[ctrl.online, ctrl.lastLogPreview, isSidebar]
|
||||
[ctrl.online, ctrl.lastLogPreview, isSidebar, showHeaderSkeleton]
|
||||
);
|
||||
|
||||
const afterBadge = showHeaderSkeleton ? (
|
||||
<>
|
||||
<LogsHeaderSkeletonPill className="h-5 w-14" />
|
||||
<span className="pointer-events-auto ml-auto inline-flex size-6 items-center justify-center rounded text-[var(--color-text-muted)] opacity-70">
|
||||
<Expand size={14} />
|
||||
</span>
|
||||
</>
|
||||
) : ctrl.data.total > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-auto ml-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
aria-label="Open fullscreen logs"
|
||||
>
|
||||
<Expand size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Fullscreen</TooltipContent>
|
||||
</Tooltip>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollapsibleTeamSection
|
||||
|
|
@ -102,27 +151,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
|
|||
title="Logs"
|
||||
icon={null}
|
||||
badge={ctrl.badge}
|
||||
afterBadge={
|
||||
ctrl.data.total > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="pointer-events-auto ml-auto size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
aria-label="Open fullscreen logs"
|
||||
>
|
||||
<Expand size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Fullscreen</TooltipContent>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
afterBadge={afterBadge}
|
||||
headerClassName={isSidebar ? '-mx-3 w-[calc(100%+1.5rem)] py-0' : undefined}
|
||||
headerSurfaceClassName={isSidebar ? '!rounded-none' : undefined}
|
||||
headerContentClassName={isSidebar ? 'flex-wrap items-center gap-y-1 py-1 pr-1' : 'pr-1'}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,11 @@ export const CollapsibleTeamSection = memo(function CollapsibleTeamSection({
|
|||
}, [isOpen, onOpenChange]);
|
||||
|
||||
return (
|
||||
<section ref={sectionRef} data-section-id={sectionId} className="min-w-0">
|
||||
<section
|
||||
ref={sectionRef}
|
||||
data-section-id={sectionId}
|
||||
className="min-w-0 [&:not(:last-child)]:mb-[10px]"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative -mx-[calc(1rem-5px)] flex min-h-9 w-[calc(100%+2rem-10px)] items-stretch py-1.5',
|
||||
|
|
|
|||
|
|
@ -332,17 +332,23 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
{visibleFiles.map((file) => (
|
||||
<div
|
||||
key={`${summary.taskId}:${file.filePath}`}
|
||||
className="group flex w-full items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={getVisibleFilePath(file)}
|
||||
className="group flex w-full cursor-pointer items-center gap-2 px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)] focus:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)]"
|
||||
onClick={() => onViewChanges(task.id, file.filePath)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onViewChanges(task.id, file.filePath);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FileIcon fileName={getVisibleFileName(file)} className="size-3.5" />
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
|
||||
onClick={() => onViewChanges(task.id, file.filePath)}
|
||||
title={getVisibleFilePath(file)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors group-hover:text-[var(--color-text)]">
|
||||
{getVisibleFilePath(file)}
|
||||
</button>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{file.linesAdded > 0 ? (
|
||||
<span className="text-emerald-400">+{file.linesAdded}</span>
|
||||
|
|
@ -357,7 +363,10 @@ export const TeamChangesSection = memo(function TeamChangesSection({
|
|||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
||||
onClick={() => onViewChanges(task.id, file.filePath)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onViewChanges(task.id, file.filePath);
|
||||
}}
|
||||
aria-label="Review diff"
|
||||
>
|
||||
<GitCompareArrows size={13} />
|
||||
|
|
|
|||
|
|
@ -60,13 +60,18 @@ import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
|||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Code,
|
||||
Columns3,
|
||||
Expand,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
History,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
Paperclip,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
|
|
@ -176,6 +181,7 @@ interface CreateTaskDialogState {
|
|||
|
||||
const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000;
|
||||
const MEMBER_ROSTER_HYDRATION_RETRY_DELAY_MS = 1_200;
|
||||
const FLOATING_COMPOSER_SCROLL_RESERVE_BASE_PX = 200;
|
||||
|
||||
function getSummaryKnownTeammateCount(summary: TeamSummary | undefined): number {
|
||||
if (!summary) {
|
||||
|
|
@ -342,59 +348,107 @@ const SkeletonPill = ({ className }: SkeletonClassNameProps): React.JSX.Element
|
|||
/>
|
||||
);
|
||||
|
||||
const TeamLoadingMessageComposerSkeleton = (): React.JSX.Element => (
|
||||
<div className="relative mb-1.5 pb-1.5" aria-hidden="true">
|
||||
<div className="mb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex size-[22px] shrink-0 items-center justify-center rounded p-1 text-[var(--color-text-muted)] opacity-70">
|
||||
<Paperclip size={14} />
|
||||
</span>
|
||||
<SkeletonPill className="h-3 w-20 rounded bg-yellow-500/20" />
|
||||
<div className="ml-auto mr-[15px] inline-flex h-[26px] shrink-0 items-center overflow-hidden rounded-b-none rounded-t-[1.35rem] border border-b-0 border-[var(--color-border)] bg-[var(--color-surface-raised)]">
|
||||
<div className="flex h-full items-center gap-1.5 border-r border-r-[var(--color-border)] px-2.5">
|
||||
<SkeletonPill className="size-2 bg-[var(--skeleton-base-dim)]" />
|
||||
<SkeletonPill className="h-3 w-16 bg-[var(--skeleton-base-dim)]" />
|
||||
<SkeletonPill className="size-3 rounded bg-[var(--skeleton-base-dim)]" />
|
||||
</div>
|
||||
<div className="flex h-full items-center gap-1.5 px-2.5">
|
||||
<SkeletonPill className="h-4 w-14 bg-[var(--skeleton-base-dim)]" />
|
||||
<SkeletonPill className="size-3 rounded bg-[var(--skeleton-base-dim)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-[2]">
|
||||
<div className="message-composer-shell relative h-[98px] overflow-hidden rounded-md border border-transparent bg-[var(--color-surface-raised)] shadow-[0_8px_24px_rgba(0,0,0,0.18),inset_0_1px_0_rgba(255,255,255,0.03)]">
|
||||
<div className="pointer-events-none absolute inset-0 rounded-md border border-[var(--color-border-emphasis)]" />
|
||||
<SkeletonPill className="absolute left-3 top-3 h-3 w-[62%] rounded bg-[var(--skeleton-base-dim)]" />
|
||||
<SkeletonPill className="absolute left-3 top-8 h-3 w-[42%] rounded bg-[var(--skeleton-base-dim)]" />
|
||||
<SkeletonPill className="absolute bottom-2 left-2 h-5 w-[68px] border border-[var(--color-border)] bg-[var(--skeleton-base-dim)]" />
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-2">
|
||||
<SkeletonPill className="size-[26px] bg-[var(--skeleton-base-dim)]" />
|
||||
<SkeletonPill className="h-[30px] w-[72px] bg-blue-600/35" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 flex items-start justify-between gap-2">
|
||||
<SkeletonPill className="h-3 w-56 max-w-[68%] rounded bg-[var(--skeleton-base-dim)]" />
|
||||
<SkeletonPill className="h-3 w-12 rounded bg-[var(--skeleton-base-dim)]" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TeamLoadingSidebarSkeleton = (): React.JSX.Element => (
|
||||
<aside
|
||||
className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]"
|
||||
aria-label="Loading team sidebar"
|
||||
>
|
||||
<div className="shrink-0 px-3 py-2">
|
||||
<div className="-mx-3 flex min-h-9 items-center gap-3 bg-[var(--color-section-bg)] px-4">
|
||||
<SkeletonPill className="size-4 rounded" />
|
||||
<SkeletonPill className="h-4 w-16" />
|
||||
<SkeletonPill className="h-5 w-8" />
|
||||
<SkeletonPill className="ml-auto size-5 rounded" />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<SkeletonPill className="size-4 rounded" />
|
||||
<SkeletonPill className="h-3.5 w-44" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-px shrink-0 bg-[var(--color-border)]" />
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-3">
|
||||
<div className="mb-3 flex min-h-9 items-center gap-3">
|
||||
<SkeletonPill className="size-4 rounded" />
|
||||
<SkeletonPill className="h-4 w-24" />
|
||||
<SkeletonPill className="h-5 w-8" />
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<SkeletonPill className="size-5 rounded" />
|
||||
<SkeletonPill className="size-5 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3">
|
||||
<SkeletonPill className="h-4 w-52" />
|
||||
<SkeletonPill className="mt-2 h-4 w-40" />
|
||||
<div className="mt-7 flex items-center gap-2">
|
||||
<SkeletonPill className="h-6 w-12" />
|
||||
<SkeletonPill className="h-6 w-16" />
|
||||
<SkeletonPill className="h-6 w-20" />
|
||||
<SkeletonPill className="ml-auto size-8 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3 overflow-hidden">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-sidebar)] p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonPill className="h-5 w-12" />
|
||||
<SkeletonPill className="h-3 w-16" />
|
||||
<SkeletonPill className="ml-auto h-3 w-12" />
|
||||
</div>
|
||||
<SkeletonPill className="mt-5 h-4 w-[88%]" />
|
||||
<SkeletonPill className="mt-2 h-4 w-[72%]" />
|
||||
<div className="shrink-0 overflow-hidden px-3">
|
||||
<section className="min-w-0">
|
||||
<div className="relative -mx-3 flex min-h-9 w-[calc(100%+1.5rem)] items-stretch py-0">
|
||||
<div className="absolute inset-0 z-0 bg-[var(--color-section-bg)]" />
|
||||
<div className="relative z-10 flex min-w-0 flex-1 basis-0 flex-wrap items-center gap-2 gap-y-1 py-1 pl-4 pr-1">
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="shrink-0 text-[var(--color-text-muted)] transition-transform duration-150"
|
||||
/>
|
||||
<SkeletonPill className="h-4 w-14" />
|
||||
<SkeletonPill className="h-5 w-14" />
|
||||
<span className="pointer-events-auto ml-auto inline-flex size-6 items-center justify-center rounded text-[var(--color-text-muted)] opacity-70">
|
||||
<Expand size={14} />
|
||||
</span>
|
||||
<span className="flex min-w-0 basis-full items-center gap-1.5 opacity-70">
|
||||
<MessageSquare size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<SkeletonPill className="h-3 w-12 rounded" />
|
||||
<SkeletonPill className="h-3 w-2 rounded" />
|
||||
<SkeletonPill className="h-3 min-w-0 flex-1 rounded" />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="bg-[var(--color-text-muted)]/35 h-px shrink-0" />
|
||||
<div className="min-h-0 flex-1">
|
||||
<div className="flex size-full flex-col overflow-hidden bg-[var(--color-surface-sidebar)]">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-sidebar)] px-3 py-2">
|
||||
<MessageSquare size={14} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
<SkeletonPill className="h-4 w-24" />
|
||||
<SkeletonPill className="h-5 w-8" />
|
||||
<span className="ml-auto inline-flex size-7 items-center justify-center rounded text-[var(--color-text-muted)] opacity-70">
|
||||
<MoreHorizontal size={15} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-hidden pb-14 pr-3 pt-2">
|
||||
<div className="pl-3">
|
||||
<TeamLoadingMessageComposerSkeleton />
|
||||
</div>
|
||||
<div className="space-y-3 overflow-hidden pl-3">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-sidebar)] p-3"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonPill className="h-5 w-12" />
|
||||
<SkeletonPill className="h-3 w-16" />
|
||||
<SkeletonPill className="ml-auto h-3 w-12" />
|
||||
</div>
|
||||
<SkeletonPill className="mt-5 h-4 w-[88%]" />
|
||||
<SkeletonPill className="mt-2 h-4 w-[72%]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
|
@ -431,9 +485,10 @@ const TeamLoadingSectionHeader = ({
|
|||
)}
|
||||
/>
|
||||
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2 pl-4">
|
||||
<span
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className={cn(
|
||||
'size-0 border-y-[5px] border-l-[6px] border-y-transparent border-l-[var(--color-text-muted)] opacity-80',
|
||||
'shrink-0 text-[var(--color-text-muted)] transition-transform duration-150',
|
||||
open && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
|
|
@ -496,7 +551,7 @@ const TeamContentLoadingSkeleton = ({
|
|||
<SkeletonPill className="h-5 w-24 rounded-md" />
|
||||
<SkeletonPill className="h-3 w-16" />
|
||||
</div>
|
||||
<SkeletonPill className="-mt-2 h-8 w-36 shrink-0 rounded-full border border-cyan-300/25 bg-cyan-500/10" />
|
||||
<SkeletonPill className="-mt-2 h-8 w-24 shrink-0 rounded-full border border-cyan-300/25 bg-cyan-500/10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -504,7 +559,7 @@ const TeamContentLoadingSkeleton = ({
|
|||
<TeamProvisioningBanner teamName={teamName} />
|
||||
</div>
|
||||
|
||||
<section className="min-w-0">
|
||||
<section className="min-w-0 [&:not(:last-child)]:mb-[10px]">
|
||||
<TeamLoadingSectionHeader
|
||||
icon={<Users size={14} />}
|
||||
titleWidth="w-20"
|
||||
|
|
@ -513,14 +568,17 @@ const TeamContentLoadingSkeleton = ({
|
|||
/>
|
||||
<div className="mt-3 grid grid-cols-1 gap-1 pb-4">
|
||||
{TEAM_LOADING_MEMBER_ACCENTS.map((accent, index) => (
|
||||
<div key={accent} className="flex min-h-[52px] min-w-0 items-center gap-3">
|
||||
<div className="relative size-7 shrink-0">
|
||||
<div key={accent} className="flex min-h-[52px] min-w-0 items-center gap-2.5">
|
||||
<div className="relative size-[34px] shrink-0">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full border-2 bg-[var(--color-surface-raised)]"
|
||||
style={{ borderColor: accent }}
|
||||
style={{
|
||||
borderColor: accent,
|
||||
boxShadow: isLight ? 'none' : `0 0 0 1px ${accent}26`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 right-0 size-2 rounded-full border border-[var(--color-surface)]"
|
||||
className="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)]"
|
||||
style={{ backgroundColor: accent }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -535,19 +593,19 @@ const TeamContentLoadingSkeleton = ({
|
|||
<div className="hidden shrink-0 items-center gap-3 sm:flex">
|
||||
<SkeletonPill className="h-[18px] w-[62px]" />
|
||||
<SkeletonPill className="h-[18px] w-[62px]" />
|
||||
<SkeletonPill className="size-4 rounded" />
|
||||
<SkeletonPill className="size-4 rounded" />
|
||||
<SkeletonPill className="size-[21px] rounded" />
|
||||
<SkeletonPill className="size-[21px] rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="min-w-0">
|
||||
<section className="min-w-0 [&:not(:last-child)]:mb-[10px]">
|
||||
<TeamLoadingSectionHeader icon={<History size={14} />} titleWidth="w-24" open={false} />
|
||||
</section>
|
||||
|
||||
<section className="mt-0 min-w-0">
|
||||
<section className="min-w-0 [&:not(:last-child)]:mb-[10px]">
|
||||
<TeamLoadingSectionHeader
|
||||
icon={<Columns3 size={14} />}
|
||||
titleWidth="w-24"
|
||||
|
|
@ -564,15 +622,15 @@ const TeamContentLoadingSkeleton = ({
|
|||
<SkeletonBlock className="h-9 w-28" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 xl:grid-cols-3">
|
||||
<div className="mt-4 grid grid-cols-12 gap-3">
|
||||
{TEAM_LOADING_KANBAN_COLUMNS.map((column) => (
|
||||
<div
|
||||
key={column.title}
|
||||
className="min-h-44 overflow-hidden rounded-lg border border-[var(--color-border)]"
|
||||
className="col-span-4 flex h-[400px] min-h-0 flex-col overflow-hidden rounded-md border border-[var(--color-border)]"
|
||||
style={{ backgroundColor: column.bodyBg }}
|
||||
>
|
||||
<div
|
||||
className="flex h-11 items-center gap-3 px-4"
|
||||
className="flex shrink-0 items-center gap-2 px-3 py-2"
|
||||
style={{ backgroundColor: column.headerBg }}
|
||||
>
|
||||
<SkeletonPill className="size-4 rounded" />
|
||||
|
|
@ -580,9 +638,9 @@ const TeamContentLoadingSkeleton = ({
|
|||
className={cn('h-4', column.title === 'IN PROGRESS' ? 'w-32' : 'w-20')}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="min-h-0 flex-1 overflow-hidden p-2">
|
||||
<div
|
||||
className="flex h-14 items-center justify-center rounded-lg border border-dashed border-[var(--color-border)]"
|
||||
className="flex h-12 items-center justify-center rounded-md border border-dashed border-[var(--color-border)]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--color-surface) 35%, transparent)',
|
||||
}}
|
||||
|
|
@ -1356,8 +1414,14 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
|
||||
null
|
||||
);
|
||||
const [floatingComposerHeight, setFloatingComposerHeight] = useState(0);
|
||||
const provisioningBannerRef = useRef<HTMLDivElement>(null);
|
||||
const wasProvisioningRef = useRef(false);
|
||||
const handleFloatingComposerHeightChange = useCallback((height: number) => {
|
||||
setFloatingComposerHeight((currentHeight) =>
|
||||
currentHeight === height ? currentHeight : height
|
||||
);
|
||||
}, []);
|
||||
const handleOpenGraphTab = useCallback(() => {
|
||||
const state = useStore.getState();
|
||||
const displayName = state.teamByName[teamName]?.displayName ?? teamName;
|
||||
|
|
@ -1410,152 +1474,6 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
return () => window.removeEventListener('toggle-team-graph', handler);
|
||||
}, [handleOpenGraphTab, teamName]);
|
||||
|
||||
// Listen for graph tab actions (open task, send message)
|
||||
useEffect(() => {
|
||||
const onOpenTask = (e: Event) => {
|
||||
const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {};
|
||||
if (tn !== teamName || !data) return;
|
||||
const task = data.tasks.find((t: { id: string }) => t.id === taskId);
|
||||
if (task) setSelectedTask(task);
|
||||
};
|
||||
const onSendMsg = (e: Event) => {
|
||||
const { teamName: tn, memberName } = (e as CustomEvent).detail ?? {};
|
||||
if (tn !== teamName) return;
|
||||
setSendDialogRecipient(memberName);
|
||||
setSendDialogDefaultText(undefined);
|
||||
setSendDialogDefaultChip(undefined);
|
||||
setSendDialogOpen(true);
|
||||
};
|
||||
const onOpenProfile = (e: Event) => {
|
||||
const {
|
||||
teamName: tn,
|
||||
memberName,
|
||||
initialTab,
|
||||
initialActivityFilter,
|
||||
} = (e as CustomEvent).detail ?? {};
|
||||
if (tn !== teamName || !data) return;
|
||||
const member = members.find((m: { name: string }) => m.name === memberName);
|
||||
if (member) {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView({
|
||||
initialTab,
|
||||
initialActivityFilter,
|
||||
});
|
||||
}
|
||||
};
|
||||
const onCreateTask = (e: Event) => {
|
||||
const { teamName: tn, owner } = (e as CustomEvent).detail ?? {};
|
||||
if (tn !== teamName) return;
|
||||
openCreateTaskDialog('', '', owner ?? '');
|
||||
};
|
||||
window.addEventListener('graph:open-task', onOpenTask);
|
||||
window.addEventListener('graph:send-message', onSendMsg);
|
||||
window.addEventListener('graph:open-profile', onOpenProfile);
|
||||
window.addEventListener('graph:create-task', onCreateTask);
|
||||
|
||||
// Task action events from graph
|
||||
const taskAction = (handler: (taskId: string) => void) => (e: Event) => {
|
||||
const { teamName: tn, taskId } = (e as CustomEvent).detail ?? {};
|
||||
if (tn !== teamName || !taskId) return;
|
||||
handler(taskId);
|
||||
};
|
||||
const onStartTask = taskAction((taskId) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await startTaskByUser(teamName, taskId);
|
||||
if (data?.isAlive) {
|
||||
const task = data.tasks.find((t: { id: string }) => t.id === taskId);
|
||||
try {
|
||||
if (result.notifiedOwner && task?.owner) {
|
||||
await api.teams.processSend(
|
||||
teamName,
|
||||
`Task ${formatTaskDisplayLabel(task)} "${task.subject}" has started. Please begin working on it.`
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* error via store */
|
||||
}
|
||||
})();
|
||||
});
|
||||
const onCompleteTask = taskAction((taskId) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await updateTaskStatus(teamName, taskId, 'completed');
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
})();
|
||||
});
|
||||
const onApproveTask = taskAction((taskId) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await updateKanban(teamName, taskId, { op: 'set_column', column: 'approved' });
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
})();
|
||||
});
|
||||
const onRequestReviewTask = taskAction((taskId) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await requestReview(teamName, taskId);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
})();
|
||||
});
|
||||
const onRequestChangesTask = taskAction((taskId) => {
|
||||
setRequestChangesTaskId(taskId);
|
||||
});
|
||||
const onCancelTask = taskAction((taskId) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await updateTaskStatus(teamName, taskId, 'pending');
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
})();
|
||||
});
|
||||
const onMoveBackToDoneTask = taskAction((taskId) => {
|
||||
void (async () => {
|
||||
try {
|
||||
await updateKanban(teamName, taskId, { op: 'remove' });
|
||||
await updateTaskStatus(teamName, taskId, 'completed');
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
})();
|
||||
});
|
||||
const onDeleteTaskGraph = taskAction((taskId) => handleDeleteTask(taskId));
|
||||
|
||||
window.addEventListener('graph:start-task', onStartTask);
|
||||
window.addEventListener('graph:complete-task', onCompleteTask);
|
||||
window.addEventListener('graph:approve-task', onApproveTask);
|
||||
window.addEventListener('graph:request-review', onRequestReviewTask);
|
||||
window.addEventListener('graph:request-changes', onRequestChangesTask);
|
||||
window.addEventListener('graph:cancel-task', onCancelTask);
|
||||
window.addEventListener('graph:move-back-to-done', onMoveBackToDoneTask);
|
||||
window.addEventListener('graph:delete-task', onDeleteTaskGraph);
|
||||
return () => {
|
||||
window.removeEventListener('graph:open-task', onOpenTask);
|
||||
window.removeEventListener('graph:send-message', onSendMsg);
|
||||
window.removeEventListener('graph:open-profile', onOpenProfile);
|
||||
window.removeEventListener('graph:create-task', onCreateTask);
|
||||
window.removeEventListener('graph:start-task', onStartTask);
|
||||
window.removeEventListener('graph:complete-task', onCompleteTask);
|
||||
window.removeEventListener('graph:approve-task', onApproveTask);
|
||||
window.removeEventListener('graph:request-review', onRequestReviewTask);
|
||||
window.removeEventListener('graph:request-changes', onRequestChangesTask);
|
||||
window.removeEventListener('graph:cancel-task', onCancelTask);
|
||||
window.removeEventListener('graph:move-back-to-done', onMoveBackToDoneTask);
|
||||
window.removeEventListener('graph:delete-task', onDeleteTaskGraph);
|
||||
};
|
||||
});
|
||||
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [stoppingTeam, setStoppingTeam] = useState(false);
|
||||
|
|
@ -2548,6 +2466,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
onReplyToMessage: handleReplyToMessage,
|
||||
onRestartTeam: handleRestartTeam,
|
||||
onTaskIdClick: handleTaskIdClick,
|
||||
onFloatingComposerHeightChange: handleFloatingComposerHeightChange,
|
||||
inlineScrollContainerRef: contentRef,
|
||||
}),
|
||||
[
|
||||
|
|
@ -2560,6 +2479,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
handleRestartTeam,
|
||||
handleSelectMember,
|
||||
handleTaskIdClick,
|
||||
handleFloatingComposerHeightChange,
|
||||
messagesPanelTasks,
|
||||
messagesPanelMountPoint,
|
||||
pendingRepliesByMember,
|
||||
|
|
@ -2695,6 +2615,11 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
const headerColorSet = data.config.color
|
||||
? getTeamColorSet(data.config.color)
|
||||
: nameColorSet(data.config.name);
|
||||
const shouldReserveFloatingComposerScrollSpace =
|
||||
messagesPanelMode === 'floating-composer' && isThisTabActive && isPaneFocused && !graphOpen;
|
||||
const floatingComposerScrollReserve = shouldReserveFloatingComposerScrollSpace
|
||||
? FLOATING_COMPOSER_SCROLL_RESERVE_BASE_PX + floatingComposerHeight
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -2737,6 +2662,7 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
<div
|
||||
ref={contentRef}
|
||||
className="size-full min-w-0 overflow-y-auto overflow-x-hidden p-4"
|
||||
style={{ paddingBottom: floatingComposerScrollReserve }}
|
||||
data-team-name={teamName}
|
||||
>
|
||||
<div className="relative -mx-4 -mt-4 mb-3 overflow-hidden border-b border-[var(--color-border)] px-4 py-3">
|
||||
|
|
@ -2967,24 +2893,28 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
</Button>
|
||||
</div>
|
||||
}
|
||||
contentWrapperClassName="-mx-[calc(1rem-5px)] w-[calc(100%+2rem-10px)]"
|
||||
>
|
||||
<TeamMemberListBridge
|
||||
teamName={teamName}
|
||||
members={membersWithLiveBranches}
|
||||
expectedTeammateCount={activeTeammateCount}
|
||||
memberTaskCounts={memberTaskCounts}
|
||||
taskMap={taskMap}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
isTeamAlive={data.isAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
launchParams={launchParams}
|
||||
onMemberClick={handleSelectMember}
|
||||
onSendMessage={handleSendMessageToMember}
|
||||
onAssignTask={handleAssignTaskToMember}
|
||||
onOpenTask={handleOpenTaskById}
|
||||
onRestartMember={handleRestartMember}
|
||||
onSkipMemberForLaunch={handleSkipMemberForLaunch}
|
||||
/>
|
||||
<div className="px-[calc(1rem-5px)]">
|
||||
<TeamMemberListBridge
|
||||
teamName={teamName}
|
||||
members={membersWithLiveBranches}
|
||||
expectedTeammateCount={activeTeammateCount}
|
||||
memberTaskCounts={memberTaskCounts}
|
||||
taskMap={taskMap}
|
||||
pendingRepliesByMember={pendingRepliesByMember}
|
||||
isRosterLoading={loading}
|
||||
isTeamAlive={data.isAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
launchParams={launchParams}
|
||||
onMemberClick={handleSelectMember}
|
||||
onSendMessage={handleSendMessageToMember}
|
||||
onAssignTask={handleAssignTaskToMember}
|
||||
onOpenTask={handleOpenTaskById}
|
||||
onRestartMember={handleRestartMember}
|
||||
onSkipMemberForLaunch={handleSkipMemberForLaunch}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
<CollapsibleTeamSection
|
||||
|
|
@ -3665,26 +3595,6 @@ export const TeamDetailView = memo(function TeamDetailView({
|
|||
isThisTabActive &&
|
||||
isPaneFocused
|
||||
}
|
||||
onSendMessage={(memberName) => {
|
||||
setSendDialogRecipient(memberName);
|
||||
setSendDialogDefaultText(undefined);
|
||||
setSendDialogDefaultChip(undefined);
|
||||
setSendDialogOpen(true);
|
||||
}}
|
||||
onOpenTaskDetail={(taskId) => {
|
||||
const task = data.tasks.find((t) => t.id === taskId);
|
||||
if (task) setSelectedTask(task);
|
||||
}}
|
||||
onOpenMemberProfile={(memberName, options) => {
|
||||
const member = members.find((m) => m.name === memberName);
|
||||
if (member) {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView({
|
||||
initialTab: options?.initialTab,
|
||||
initialActivityFilter: options?.initialActivityFilter,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||
|
||||
import { TeamEmptyState } from './TeamEmptyState';
|
||||
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
|
||||
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
|
||||
import {
|
||||
findTeamProjectSelectionTarget,
|
||||
resolveTeamProjectSelection,
|
||||
|
|
@ -63,6 +64,7 @@ import {
|
|||
import { TeamTaskStatusSummary } from './TeamTaskStatusSummary';
|
||||
|
||||
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
|
||||
import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
|
||||
import type { TeamListFilterState } from './TeamListFilterPopover';
|
||||
import type { TeamStatus } from '@renderer/utils/teamListStatus';
|
||||
import type {
|
||||
|
|
@ -267,6 +269,7 @@ interface ActiveTeamCardProps {
|
|||
onLaunchTeam: (
|
||||
teamName: string,
|
||||
projectPath: string | undefined,
|
||||
mode: TeamLaunchDialogMode,
|
||||
event: React.MouseEvent
|
||||
) => void;
|
||||
onStopTeam: (teamName: string, event: React.MouseEvent) => void;
|
||||
|
|
@ -297,6 +300,8 @@ const ActiveTeamCard = ({
|
|||
status === 'partial_skipped' ||
|
||||
status === 'partial_pending') &&
|
||||
Boolean(team.projectPath);
|
||||
const launchMode: TeamLaunchDialogMode = status === 'offline' ? 'launch' : 'relaunch';
|
||||
const launchLabel = launchMode === 'relaunch' ? 'Relaunch team' : 'Launch team';
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -347,16 +352,21 @@ const ActiveTeamCard = ({
|
|||
type="button"
|
||||
className="shrink-0 rounded p-1 text-[var(--color-text-muted)] opacity-0 transition-opacity hover:bg-emerald-500/10 hover:text-emerald-300 disabled:opacity-50 group-hover:opacity-100"
|
||||
onClick={(event) =>
|
||||
onLaunchTeam(team.teamName, team.projectPath ?? undefined, event)
|
||||
onLaunchTeam(
|
||||
team.teamName,
|
||||
team.projectPath ?? undefined,
|
||||
launchMode,
|
||||
event
|
||||
)
|
||||
}
|
||||
disabled={launchingTeamName === team.teamName}
|
||||
aria-label="Launch team"
|
||||
aria-label={launchLabel}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'}
|
||||
{launchingTeamName === team.teamName ? 'Launching…' : launchLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
|
@ -867,18 +877,25 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
|
|||
|
||||
const [launchingTeamName, setLaunchingTeamName] = useState<string | null>(null);
|
||||
const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
|
||||
const [launchDialogMode, setLaunchDialogMode] = useState<TeamLaunchDialogMode>('launch');
|
||||
const [launchDialogTeamName, setLaunchDialogTeamName] = useState('');
|
||||
const [launchDialogMembers, setLaunchDialogMembers] = useState<ResolvedTeamMember[]>([]);
|
||||
const [launchDialogDefaultPath, setLaunchDialogDefaultPath] = useState<string | undefined>();
|
||||
|
||||
const handleLaunchTeam = useCallback(
|
||||
async (teamName: string, projectPath: string | undefined, e: React.MouseEvent) => {
|
||||
async (
|
||||
teamName: string,
|
||||
projectPath: string | undefined,
|
||||
mode: TeamLaunchDialogMode,
|
||||
e: React.MouseEvent
|
||||
) => {
|
||||
e.stopPropagation();
|
||||
if (!projectPath) return;
|
||||
try {
|
||||
const data = await api.teams.getData(teamName, {
|
||||
includeMemberBranches: false,
|
||||
});
|
||||
setLaunchDialogMode(mode);
|
||||
setLaunchDialogTeamName(teamName);
|
||||
setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? []));
|
||||
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath);
|
||||
|
|
@ -889,6 +906,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
|
|||
console.error('Failed to load team data for launch dialog:', err);
|
||||
}
|
||||
// Fallback: open dialog with minimal data
|
||||
setLaunchDialogMode(mode);
|
||||
setLaunchDialogTeamName(teamName);
|
||||
setLaunchDialogMembers([]);
|
||||
setLaunchDialogDefaultPath(projectPath);
|
||||
|
|
@ -913,6 +931,30 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
|
|||
[launchTeam]
|
||||
);
|
||||
|
||||
const handleRelaunchSubmit = useCallback(
|
||||
async (request: TeamLaunchRequest, members: TeamCreateRequest['members']) => {
|
||||
setLaunchingTeamName(request.teamName);
|
||||
try {
|
||||
await executeTeamRelaunch({
|
||||
teamName: request.teamName,
|
||||
isTeamAlive: true,
|
||||
request,
|
||||
members,
|
||||
stopTeam: (nextTeamName) => api.teams.stop(nextTeamName),
|
||||
replaceMembers: (nextTeamName, nextRequest) =>
|
||||
api.teams.replaceMembers(nextTeamName, nextRequest),
|
||||
launchTeam,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to relaunch team:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLaunchingTeamName(null);
|
||||
}
|
||||
},
|
||||
[launchTeam]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!electronMode) {
|
||||
return;
|
||||
|
|
@ -982,18 +1024,33 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
|
|||
|
||||
const launchDialogElement = launchDialogOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LaunchTeamDialog
|
||||
mode="launch"
|
||||
open={launchDialogOpen}
|
||||
teamName={launchDialogTeamName}
|
||||
members={launchDialogMembers}
|
||||
defaultProjectPath={launchDialogDefaultPath}
|
||||
provisioningError={provisioningErrorByTeam[launchDialogTeamName] ?? null}
|
||||
clearProvisioningError={clearProvisioningError}
|
||||
activeTeams={activeTeams}
|
||||
onClose={() => setLaunchDialogOpen(false)}
|
||||
onLaunch={handleLaunchSubmit}
|
||||
/>
|
||||
{launchDialogMode === 'relaunch' ? (
|
||||
<LaunchTeamDialog
|
||||
mode="relaunch"
|
||||
open={launchDialogOpen}
|
||||
teamName={launchDialogTeamName}
|
||||
members={launchDialogMembers}
|
||||
defaultProjectPath={launchDialogDefaultPath}
|
||||
provisioningError={provisioningErrorByTeam[launchDialogTeamName] ?? null}
|
||||
clearProvisioningError={clearProvisioningError}
|
||||
activeTeams={activeTeams}
|
||||
onClose={() => setLaunchDialogOpen(false)}
|
||||
onRelaunch={handleRelaunchSubmit}
|
||||
/>
|
||||
) : (
|
||||
<LaunchTeamDialog
|
||||
mode="launch"
|
||||
open={launchDialogOpen}
|
||||
teamName={launchDialogTeamName}
|
||||
members={launchDialogMembers}
|
||||
defaultProjectPath={launchDialogDefaultPath}
|
||||
provisioningError={provisioningErrorByTeam[launchDialogTeamName] ?? null}
|
||||
clearProvisioningError={clearProvisioningError}
|
||||
activeTeams={activeTeams}
|
||||
onClose={() => setLaunchDialogOpen(false)}
|
||||
onLaunch={handleLaunchSubmit}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -155,6 +155,15 @@ function malformedLegacyChangeSet(): TaskChangeSetV2 {
|
|||
} as unknown as TaskChangeSetV2;
|
||||
}
|
||||
|
||||
function malformedUnknownChangeSet(): TaskChangeSetV2 {
|
||||
return {
|
||||
...changeSet(),
|
||||
confidence: 'fallback',
|
||||
files: undefined,
|
||||
warnings: undefined,
|
||||
} as unknown as TaskChangeSetV2;
|
||||
}
|
||||
|
||||
function malformedResponse(): TeamTaskChangeSummariesResponse {
|
||||
return {
|
||||
teamName: 'team-a',
|
||||
|
|
@ -196,6 +205,25 @@ function incompleteChangeSetResponse(): TeamTaskChangeSummariesResponse {
|
|||
} as unknown as TeamTaskChangeSummariesResponse;
|
||||
}
|
||||
|
||||
function quietNoLogChangeSet(): TaskChangeSetV2 {
|
||||
return {
|
||||
...changeSet(),
|
||||
confidence: 'fallback',
|
||||
scope: {
|
||||
taskId: 'task-1',
|
||||
memberName: '',
|
||||
startLine: 0,
|
||||
endLine: 0,
|
||||
startTimestamp: '',
|
||||
endTimestamp: '',
|
||||
toolUseIds: [],
|
||||
filePaths: [],
|
||||
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
function lowConfidenceFileResponse(): TeamTaskChangeSummariesResponse {
|
||||
return response({
|
||||
...changeSet(),
|
||||
|
|
@ -664,6 +692,160 @@ describe('useTeamChangesSummaries', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('does not render active no-log summaries as Changes warnings', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
|
||||
|
||||
const scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
'scrollIntoView'
|
||||
);
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
try {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(
|
||||
TooltipProvider,
|
||||
null,
|
||||
React.createElement(TeamChangesSection, {
|
||||
teamName: 'team-a',
|
||||
tasks: [task({ status: 'in_progress', changePresence: 'needs_attention' })],
|
||||
onOpenTask: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const expandButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Expand section"]'
|
||||
);
|
||||
expect(expandButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
expandButton?.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain('No file changes recorded');
|
||||
expect(container.textContent).not.toContain('No log files found for this task.');
|
||||
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
||||
expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||
'team-a',
|
||||
'task-1',
|
||||
'unknown'
|
||||
);
|
||||
} finally {
|
||||
if (scrollIntoViewDescriptor) {
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
||||
} else {
|
||||
delete (Element.prototype as { scrollIntoView?: Element['scrollIntoView'] }).scrollIntoView;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('does not clear completed task presence from an uncertain empty summary', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
tasks: [task({ status: 'completed', changePresence: 'needs_attention' })],
|
||||
onSnapshot: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
||||
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears stale selected presence for newly created pending tasks without logs', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
tasks: [task({ status: 'pending', changePresence: 'needs_attention' })],
|
||||
onSnapshot: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
||||
expect(hoisted.setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
|
||||
'team-a',
|
||||
'task-1',
|
||||
'unknown'
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the first duplicate task id when deciding whether to clear stale presence', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(quietNoLogChangeSet()));
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
tasks: [
|
||||
task({ status: 'completed', changePresence: 'needs_attention' }),
|
||||
task({ status: 'in_progress', changePresence: 'needs_attention' }),
|
||||
],
|
||||
onSnapshot: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
||||
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not clear task presence from malformed unknown summaries', async () => {
|
||||
hoisted.getTeamTaskChangeSummaries.mockResolvedValue(response(malformedUnknownChangeSet()));
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root?.render(
|
||||
React.createElement(HookHarness, {
|
||||
tasks: [task({ status: 'in_progress', changePresence: 'needs_attention' })],
|
||||
onSnapshot: vi.fn(),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(hoisted.recordTaskChangePresence).not.toHaveBeenCalled();
|
||||
expect(hoisted.setSelectedTeamTaskChangePresence).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows the closed-section counter only after the background count load resolves', async () => {
|
||||
const deferred = createDeferred<TeamTaskChangeSummariesResponse>();
|
||||
hoisted.getTeamTaskChangeSummaries.mockReturnValue(deferred.promise);
|
||||
|
|
@ -1222,6 +1404,32 @@ describe('useTeamChangesSummaries', () => {
|
|||
});
|
||||
|
||||
expect(onViewChanges).toHaveBeenCalledWith('task-1');
|
||||
|
||||
onViewChanges.mockClear();
|
||||
|
||||
const fileRow = container.querySelector<HTMLElement>('[role="button"][title="src/app.ts"]');
|
||||
expect(fileRow).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
fileRow?.click();
|
||||
});
|
||||
|
||||
expect(onViewChanges).toHaveBeenCalledTimes(1);
|
||||
expect(onViewChanges).toHaveBeenCalledWith('task-1', '/repo/src/app.ts');
|
||||
|
||||
onViewChanges.mockClear();
|
||||
|
||||
const reviewFileDiffButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Review diff"]'
|
||||
);
|
||||
expect(reviewFileDiffButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
reviewFileDiffButton?.click();
|
||||
});
|
||||
|
||||
expect(onViewChanges).toHaveBeenCalledTimes(1);
|
||||
expect(onViewChanges).toHaveBeenCalledWith('task-1', '/repo/src/app.ts');
|
||||
} finally {
|
||||
if (scrollIntoViewDescriptor) {
|
||||
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
|
||||
|
|
|
|||
|
|
@ -103,8 +103,8 @@ import { loadProjectPathProjects, type ProjectPathProject } from './projectPathP
|
|||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||
import {
|
||||
buildReusableProviderPrepareModelResults,
|
||||
getProviderPrepareCachedSnapshot,
|
||||
mergeReusableProviderPrepareModelResults,
|
||||
type ProviderPrepareDiagnosticsModelResult,
|
||||
runProviderPrepareDiagnostics,
|
||||
} from './providerPrepareDiagnostics';
|
||||
|
|
@ -776,6 +776,9 @@ export const CreateTeamDialog = ({
|
|||
]
|
||||
);
|
||||
const shortLivedModelIssueReasons = useMemo(() => {
|
||||
void prepareChecks;
|
||||
const modelAdvisoryReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> =
|
||||
{};
|
||||
const modelIssueReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> = {};
|
||||
const modelUnavailableReasonByProvider: Partial<
|
||||
Record<TeamProviderId, Record<string, string>>
|
||||
|
|
@ -794,6 +797,9 @@ export const CreateTeamDialog = ({
|
|||
providerId,
|
||||
cacheKey,
|
||||
});
|
||||
if (Object.keys(issueReasons.modelAdvisoryReasonByValue).length > 0) {
|
||||
modelAdvisoryReasonByProvider[providerId] = issueReasons.modelAdvisoryReasonByValue;
|
||||
}
|
||||
if (Object.keys(issueReasons.modelIssueReasonByValue).length > 0) {
|
||||
modelIssueReasonByProvider[providerId] = issueReasons.modelIssueReasonByValue;
|
||||
}
|
||||
|
|
@ -803,12 +809,14 @@ export const CreateTeamDialog = ({
|
|||
}
|
||||
|
||||
return {
|
||||
modelAdvisoryReasonByProvider,
|
||||
modelIssueReasonByProvider,
|
||||
modelUnavailableReasonByProvider,
|
||||
};
|
||||
}, [
|
||||
effectiveAnthropicRuntimeLimitContext,
|
||||
effectiveCwd,
|
||||
prepareChecks,
|
||||
prepareRuntimeStatusSignature,
|
||||
runtimeBackendSummaryByProvider,
|
||||
selectedMemberProviders,
|
||||
|
|
@ -1037,10 +1045,13 @@ export const CreateTeamDialog = ({
|
|||
anyNotes = true;
|
||||
}
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
const reusableModelResults = buildReusableProviderPrepareModelResults(
|
||||
plan.prepResult.modelResultsById
|
||||
prepareModelResultsCacheRef.current.set(
|
||||
plan.cacheKey,
|
||||
mergeReusableProviderPrepareModelResults(
|
||||
prepareModelResultsCacheRef.current.get(plan.cacheKey),
|
||||
plan.prepResult.modelResultsById
|
||||
)
|
||||
);
|
||||
prepareModelResultsCacheRef.current.set(plan.cacheKey, reusableModelResults);
|
||||
storeShortLivedProviderPrepareModelResults({
|
||||
providerId: plan.providerId,
|
||||
cacheKey: plan.cacheKey,
|
||||
|
|
@ -1499,7 +1510,11 @@ export const CreateTeamDialog = ({
|
|||
teamName: sanitizedTeamName,
|
||||
description: description.trim() || undefined,
|
||||
color: teamColor || undefined,
|
||||
members: soloTeam ? [] : buildMembersFromDrafts(effectiveMemberDrafts),
|
||||
members: soloTeam
|
||||
? []
|
||||
: buildMembersFromDrafts(effectiveMemberDrafts, {
|
||||
inheritedProviderId: selectedProviderId,
|
||||
}),
|
||||
cwd: effectiveCwd,
|
||||
prompt: prompt.trim() || undefined,
|
||||
providerId: selectedProviderId,
|
||||
|
|
@ -2064,6 +2079,9 @@ export const CreateTeamDialog = ({
|
|||
leadModelIssueText={leadModelIssueText}
|
||||
memberWarningById={teammateRuntimeCompatibility.memberWarningById}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
modelAdvisoryReasonByProvider={
|
||||
shortLivedModelIssueReasons.modelAdvisoryReasonByProvider
|
||||
}
|
||||
modelIssueReasonByProvider={shortLivedModelIssueReasons.modelIssueReasonByProvider}
|
||||
modelUnavailableReasonByProvider={
|
||||
shortLivedModelIssueReasons.modelUnavailableReasonByProvider
|
||||
|
|
|
|||
|
|
@ -107,8 +107,8 @@ import { loadProjectPathProjects, type ProjectPathProject } from './projectPathP
|
|||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
|
||||
import {
|
||||
buildReusableProviderPrepareModelResults,
|
||||
getProviderPrepareCachedSnapshot,
|
||||
mergeReusableProviderPrepareModelResults,
|
||||
type ProviderPrepareDiagnosticsModelResult,
|
||||
runProviderPrepareDiagnostics,
|
||||
} from './providerPrepareDiagnostics';
|
||||
|
|
@ -1430,6 +1430,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
]
|
||||
);
|
||||
const shortLivedModelIssueReasons = useMemo(() => {
|
||||
const modelAdvisoryReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> =
|
||||
{};
|
||||
const modelIssueReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> = {};
|
||||
const modelUnavailableReasonByProvider: Partial<
|
||||
Record<TeamProviderId, Record<string, string>>
|
||||
|
|
@ -1437,6 +1439,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
|
||||
if (!isLaunchMode) {
|
||||
return {
|
||||
modelAdvisoryReasonByProvider,
|
||||
modelIssueReasonByProvider,
|
||||
modelUnavailableReasonByProvider,
|
||||
};
|
||||
|
|
@ -1455,6 +1458,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
providerId,
|
||||
cacheKey,
|
||||
});
|
||||
if (Object.keys(issueReasons.modelAdvisoryReasonByValue).length > 0) {
|
||||
modelAdvisoryReasonByProvider[providerId] = issueReasons.modelAdvisoryReasonByValue;
|
||||
}
|
||||
if (Object.keys(issueReasons.modelIssueReasonByValue).length > 0) {
|
||||
modelIssueReasonByProvider[providerId] = issueReasons.modelIssueReasonByValue;
|
||||
}
|
||||
|
|
@ -1464,6 +1470,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
}
|
||||
|
||||
return {
|
||||
modelAdvisoryReasonByProvider,
|
||||
modelIssueReasonByProvider,
|
||||
modelUnavailableReasonByProvider,
|
||||
};
|
||||
|
|
@ -1615,10 +1622,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
anyNotes = true;
|
||||
}
|
||||
if (prepareRequestSeqRef.current === requestSeq) {
|
||||
const reusableModelResults = buildReusableProviderPrepareModelResults(
|
||||
plan.prepResult.modelResultsById
|
||||
prepareModelResultsCacheRef.current.set(
|
||||
plan.cacheKey,
|
||||
mergeReusableProviderPrepareModelResults(
|
||||
prepareModelResultsCacheRef.current.get(plan.cacheKey),
|
||||
plan.prepResult.modelResultsById
|
||||
)
|
||||
);
|
||||
prepareModelResultsCacheRef.current.set(plan.cacheKey, reusableModelResults);
|
||||
storeShortLivedProviderPrepareModelResults({
|
||||
providerId: plan.providerId,
|
||||
cacheKey: plan.cacheKey,
|
||||
|
|
@ -2108,7 +2118,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
void (async () => {
|
||||
try {
|
||||
if (isLaunchMode) {
|
||||
const nextMembers = buildMembersFromDrafts(effectiveMemberDrafts);
|
||||
const nextMembers = buildMembersFromDrafts(effectiveMemberDrafts, {
|
||||
inheritedProviderId: selectedProviderId,
|
||||
});
|
||||
const launchRequest: TeamLaunchRequest = {
|
||||
teamName: effectiveTeamName,
|
||||
cwd: effectiveCwd,
|
||||
|
|
@ -2316,7 +2328,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={isSchedule ? 'max-h-[90vh] max-w-3xl overflow-y-auto' : 'max-w-3xl'}
|
||||
className={isSchedule ? 'max-h-[90vh] max-w-[52rem] overflow-y-auto' : 'max-w-[52rem]'}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{dialogTitle}</DialogTitle>
|
||||
|
|
@ -2616,6 +2628,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
memberInfoById={memberWorktreeContinuationInfoById}
|
||||
leadModelIssueText={leadModelIssueText}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
modelAdvisoryReasonByProvider={
|
||||
shortLivedModelIssueReasons.modelAdvisoryReasonByProvider
|
||||
}
|
||||
modelIssueReasonByProvider={
|
||||
shortLivedModelIssueReasons.modelIssueReasonByProvider
|
||||
}
|
||||
|
|
|
|||
|
|
@ -583,6 +583,15 @@ export const TaskDetailDialog = ({
|
|||
});
|
||||
}, [requestTaskChangeSummary]);
|
||||
|
||||
const handleTaskChangeFileOpen = useCallback(
|
||||
(filePath: string): void => {
|
||||
if (!currentTask || !onViewChanges) return;
|
||||
handleClose();
|
||||
onViewChanges(currentTask.id, filePath);
|
||||
},
|
||||
[currentTask, handleClose, onViewChanges]
|
||||
);
|
||||
|
||||
const handleDependencyClick = (taskId: string): void => {
|
||||
// Resolve short displayId (e.g. "8ce74455") to full UUID via taskMap,
|
||||
// since kanban cards use the full UUID in data-task-id.
|
||||
|
|
@ -1301,28 +1310,36 @@ export const TaskDetailDialog = ({
|
|||
{taskChangesFiles.map((file) => (
|
||||
<div
|
||||
key={file.filePath}
|
||||
className="group flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]"
|
||||
role={onViewChanges ? 'button' : undefined}
|
||||
tabIndex={onViewChanges ? 0 : undefined}
|
||||
title={file.relativePath}
|
||||
className={`group flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)] focus:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
|
||||
onViewChanges ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={
|
||||
onViewChanges
|
||||
? () => handleTaskChangeFileOpen(file.filePath)
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
onViewChanges
|
||||
? (event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleTaskChangeFileOpen(file.filePath);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FileIcon
|
||||
fileName={file.relativePath.split(/[\\/]/).pop() ?? file.relativePath}
|
||||
className="size-3.5"
|
||||
/>
|
||||
{onViewChanges ? (
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text)]"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onViewChanges(currentTask.id, file.filePath);
|
||||
}}
|
||||
>
|
||||
{file.relativePath}
|
||||
</button>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)]">
|
||||
{file.relativePath}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-left font-mono text-[var(--color-text-secondary)] transition-colors group-hover:text-[var(--color-text)]">
|
||||
{file.relativePath}
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{file.linesAdded > 0 ? (
|
||||
<span className="text-emerald-400">+{file.linesAdded}</span>
|
||||
|
|
@ -1338,9 +1355,9 @@ export const TaskDetailDialog = ({
|
|||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onViewChanges(currentTask.id, file.filePath);
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleTaskChangeFileOpen(file.filePath);
|
||||
}}
|
||||
>
|
||||
<GitCompareArrows size={13} />
|
||||
|
|
@ -1355,7 +1372,10 @@ export const TaskDetailDialog = ({
|
|||
<button
|
||||
type="button"
|
||||
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
|
||||
onClick={() => onOpenInEditor(file.filePath)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onOpenInEditor(file.filePath);
|
||||
}}
|
||||
>
|
||||
<SquarePen size={13} />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
|
|
@ -35,12 +35,12 @@ import {
|
|||
import {
|
||||
compareTeamModelRecommendations,
|
||||
getTeamModelRecommendation,
|
||||
isTeamModelRecommended,
|
||||
} from '@renderer/utils/teamModelRecommendations';
|
||||
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
|
||||
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
|
||||
import { parseOpenCodeQualifiedModelRef } from '@shared/utils/opencodeModelRef';
|
||||
import { isTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import {
|
||||
AlertTriangle,
|
||||
|
|
@ -71,12 +71,44 @@ interface OpenCodeSourceOption {
|
|||
count: number;
|
||||
}
|
||||
|
||||
interface OpenCodeSourceInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface OpenCodeModelGroup {
|
||||
sourceId: string;
|
||||
sourceLabel: string;
|
||||
options: TeamRuntimeModelOption[];
|
||||
}
|
||||
|
||||
interface OpenCodeModelOptionMetadata {
|
||||
option: TeamRuntimeModelOption;
|
||||
index: number;
|
||||
sourceInfo: OpenCodeSourceInfo | null;
|
||||
recommendation: ReturnType<typeof getTeamModelRecommendation>;
|
||||
pricingInfo: OpenCodeModelPricingInfo | null;
|
||||
searchText: string;
|
||||
isRecommended: boolean;
|
||||
}
|
||||
|
||||
interface OpenCodeVirtualHeadingRow {
|
||||
kind: 'heading';
|
||||
key: string;
|
||||
sourceLabel: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface OpenCodeVirtualModelRow {
|
||||
kind: 'models';
|
||||
key: string;
|
||||
options: TeamRuntimeModelOption[];
|
||||
isLastInGroup: boolean;
|
||||
}
|
||||
|
||||
type OpenCodeVirtualRow = OpenCodeVirtualHeadingRow | OpenCodeVirtualModelRow;
|
||||
type RenderModelOption = (option: TeamRuntimeModelOption) => React.JSX.Element;
|
||||
|
||||
type ProviderModelCatalogItem = NonNullable<CliProviderStatus['modelCatalog']>['models'][number];
|
||||
|
||||
interface OpenCodeModelCostRates {
|
||||
|
|
@ -92,6 +124,13 @@ interface OpenCodeModelPricingInfo {
|
|||
title: string | undefined;
|
||||
}
|
||||
|
||||
const MODEL_GRID_MIN_CARD_WIDTH_PX = 140;
|
||||
const MODEL_GRID_GAP_PX = 6;
|
||||
const OPENCODE_MODEL_GRID_MAX_HEIGHT_PX = 400;
|
||||
const OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD = 80;
|
||||
const OPENCODE_MODEL_GROUP_HEADING_ESTIMATE_PX = 28;
|
||||
const OPENCODE_MODEL_ROW_ESTIMATE_PX = 92;
|
||||
|
||||
const PROVIDERS: ProviderDef[] = [
|
||||
{ id: 'anthropic', label: 'Anthropic', comingSoon: false },
|
||||
{ id: 'codex', label: 'Codex', comingSoon: false },
|
||||
|
|
@ -99,7 +138,7 @@ const PROVIDERS: ProviderDef[] = [
|
|||
{ id: 'opencode', label: 'OpenCode', comingSoon: false },
|
||||
];
|
||||
|
||||
function getOpenCodeSourceInfo(model: string): { id: string; label: string } | null {
|
||||
function getOpenCodeSourceInfo(model: string): OpenCodeSourceInfo | null {
|
||||
const parsed = parseOpenCodeQualifiedModelRef(model);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
|
|
@ -111,6 +150,92 @@ function getOpenCodeSourceInfo(model: string): { id: string; label: string } | n
|
|||
};
|
||||
}
|
||||
|
||||
function isRecommendedTeamModelRecommendation(
|
||||
recommendation: ReturnType<typeof getTeamModelRecommendation>
|
||||
): boolean {
|
||||
return (
|
||||
recommendation?.level === 'recommended' || recommendation?.level === 'recommended-with-limits'
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenCodeModelSearchText({
|
||||
option,
|
||||
sourceInfo,
|
||||
recommendation,
|
||||
pricingInfo,
|
||||
}: {
|
||||
option: TeamRuntimeModelOption;
|
||||
sourceInfo: OpenCodeSourceInfo | null;
|
||||
recommendation: ReturnType<typeof getTeamModelRecommendation>;
|
||||
pricingInfo: OpenCodeModelPricingInfo | null;
|
||||
}): string {
|
||||
return [
|
||||
option.value,
|
||||
option.label,
|
||||
option.badgeLabel ?? '',
|
||||
sourceInfo?.label ?? '',
|
||||
recommendation?.label ?? '',
|
||||
recommendation?.reason ?? '',
|
||||
pricingInfo?.free ? 'free' : '',
|
||||
pricingInfo?.summary ?? '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function getOpenCodeModelGridColumnCount(width: number): number {
|
||||
const safeWidth = Number.isFinite(width) ? Math.max(0, width) : 0;
|
||||
if (safeWidth <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor((safeWidth + MODEL_GRID_GAP_PX) / (MODEL_GRID_MIN_CARD_WIDTH_PX + MODEL_GRID_GAP_PX))
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenCodeVirtualRows({
|
||||
defaultOptions,
|
||||
groups,
|
||||
columnCount,
|
||||
}: {
|
||||
defaultOptions: TeamRuntimeModelOption[];
|
||||
groups: OpenCodeModelGroup[];
|
||||
columnCount: number;
|
||||
}): OpenCodeVirtualRow[] {
|
||||
const rows: OpenCodeVirtualRow[] = [];
|
||||
|
||||
if (defaultOptions.length > 0) {
|
||||
rows.push({
|
||||
kind: 'models',
|
||||
key: 'default',
|
||||
options: defaultOptions,
|
||||
isLastInGroup: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
rows.push({
|
||||
kind: 'heading',
|
||||
key: `heading:${group.sourceId}`,
|
||||
sourceLabel: group.sourceLabel,
|
||||
count: group.options.length,
|
||||
});
|
||||
|
||||
for (let start = 0; start < group.options.length; start += columnCount) {
|
||||
rows.push({
|
||||
kind: 'models',
|
||||
key: `models:${group.sourceId}:${start}`,
|
||||
options: group.options.slice(start, start + columnCount),
|
||||
isLastInGroup: start + columnCount >= group.options.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getRecordValue(record: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (key in record) {
|
||||
|
|
@ -283,6 +408,111 @@ export function computeEffectiveTeamModel(
|
|||
);
|
||||
}
|
||||
|
||||
const OpenCodeVirtualizedModelGrid = ({
|
||||
defaultOptions,
|
||||
groups,
|
||||
renderModelOption,
|
||||
}: Readonly<{
|
||||
defaultOptions: TeamRuntimeModelOption[];
|
||||
groups: OpenCodeModelGroup[];
|
||||
renderModelOption: RenderModelOption;
|
||||
}>): React.JSX.Element => {
|
||||
const scrollParentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [gridWidth, setGridWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const element = scrollParentRef.current;
|
||||
if (!element) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const updateGridWidth = (): void => setGridWidth(element.clientWidth);
|
||||
updateGridWidth();
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const resizeObserver = new ResizeObserver(updateGridWidth);
|
||||
resizeObserver.observe(element);
|
||||
return () => resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateGridWidth);
|
||||
return () => window.removeEventListener('resize', updateGridWidth);
|
||||
}, []);
|
||||
|
||||
const columnCount = useMemo(() => getOpenCodeModelGridColumnCount(gridWidth), [gridWidth]);
|
||||
const rows = useMemo(
|
||||
() => buildOpenCodeVirtualRows({ defaultOptions, groups, columnCount }),
|
||||
[columnCount, defaultOptions, groups]
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/incompatible-library -- TanStack Virtual API limitation, not fixable in user code
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => scrollParentRef.current,
|
||||
estimateSize: (index) =>
|
||||
rows[index]?.kind === 'heading'
|
||||
? OPENCODE_MODEL_GROUP_HEADING_ESTIMATE_PX
|
||||
: OPENCODE_MODEL_ROW_ESTIMATE_PX,
|
||||
overscan: 6,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollParentRef}
|
||||
data-testid="team-model-selector-model-grid"
|
||||
className="overflow-y-auto rounded-md bg-[var(--color-surface)] pr-1"
|
||||
style={{ maxHeight: OPENCODE_MODEL_GRID_MAX_HEIGHT_PX }}
|
||||
>
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{row.kind === 'heading' ? (
|
||||
<div data-testid="team-model-selector-opencode-group" className="pb-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="truncate text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-secondary)]">
|
||||
{row.sourceLabel}
|
||||
</h4>
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{row.count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn('grid gap-1.5', row.isLastInGroup ? 'pb-3' : 'pb-1.5')}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{row.options.map(renderModelOption)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface TeamModelSelectorProps {
|
||||
providerId: TeamProviderId;
|
||||
onProviderChange: (providerId: TeamProviderId) => void;
|
||||
|
|
@ -292,6 +522,7 @@ export interface TeamModelSelectorProps {
|
|||
disableGeminiOption?: boolean;
|
||||
providerDisabledReasonById?: Partial<Record<TeamProviderId, string | null | undefined>>;
|
||||
providerDisabledBadgeLabelById?: Partial<Record<TeamProviderId, string | null | undefined>>;
|
||||
modelAdvisoryReasonByValue?: Partial<Record<string, string | null | undefined>>;
|
||||
modelIssueReasonByValue?: Partial<Record<string, string | null | undefined>>;
|
||||
modelUnavailableReasonByValue?: Partial<Record<string, string | null | undefined>>;
|
||||
}
|
||||
|
|
@ -305,6 +536,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
disableGeminiOption = false,
|
||||
providerDisabledReasonById,
|
||||
providerDisabledBadgeLabelById,
|
||||
modelAdvisoryReasonByValue,
|
||||
modelIssueReasonByValue,
|
||||
modelUnavailableReasonByValue,
|
||||
}) => {
|
||||
|
|
@ -462,22 +694,50 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
|
||||
for (const model of catalog.models) {
|
||||
const launchModel = model.launchModel.trim();
|
||||
const id = model.id.trim();
|
||||
const catalogModelId = model.id.trim();
|
||||
if (launchModel) {
|
||||
modelById.set(launchModel, model);
|
||||
}
|
||||
if (id) {
|
||||
modelById.set(id, model);
|
||||
if (catalogModelId) {
|
||||
modelById.set(catalogModelId, model);
|
||||
}
|
||||
}
|
||||
|
||||
return modelById;
|
||||
}, [effectiveProviderId, runtimeProviderStatus?.modelCatalog]);
|
||||
const openCodeModelMetadata = useMemo<OpenCodeModelOptionMetadata[]>(() => {
|
||||
if (effectiveProviderId !== 'opencode') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return modelOptions.map((option, index) => {
|
||||
const sourceInfo = getOpenCodeSourceInfo(option.value);
|
||||
const recommendation = getTeamModelRecommendation(effectiveProviderId, option.value);
|
||||
const pricingInfo = getOpenCodeModelPricingInfo(openCodeCatalogModelById.get(option.value));
|
||||
|
||||
return {
|
||||
option,
|
||||
index,
|
||||
sourceInfo,
|
||||
recommendation,
|
||||
pricingInfo,
|
||||
searchText: buildOpenCodeModelSearchText({
|
||||
option,
|
||||
sourceInfo,
|
||||
recommendation,
|
||||
pricingInfo,
|
||||
}),
|
||||
isRecommended: isRecommendedTeamModelRecommendation(recommendation),
|
||||
};
|
||||
});
|
||||
}, [effectiveProviderId, modelOptions, openCodeCatalogModelById]);
|
||||
const openCodeModelMetadataByValue = useMemo(
|
||||
() => new Map(openCodeModelMetadata.map((metadata) => [metadata.option.value, metadata])),
|
||||
[openCodeModelMetadata]
|
||||
);
|
||||
const hasRecommendedOpenCodeModels = useMemo(
|
||||
() =>
|
||||
effectiveProviderId === 'opencode' &&
|
||||
modelOptions.some((option) => isTeamModelRecommended(effectiveProviderId, option.value)),
|
||||
[effectiveProviderId, modelOptions]
|
||||
() => openCodeModelMetadata.some((metadata) => metadata.isRecommended),
|
||||
[openCodeModelMetadata]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -511,15 +771,16 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
}
|
||||
|
||||
const sourceOptions = new Map<string, OpenCodeSourceOption>();
|
||||
for (const option of modelOptions) {
|
||||
for (const metadata of openCodeModelMetadata) {
|
||||
const option = metadata.option;
|
||||
if (!option.value.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (recommendedOnly && !isTeamModelRecommended(effectiveProviderId, option.value)) {
|
||||
if (recommendedOnly && !metadata.isRecommended) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceInfo = getOpenCodeSourceInfo(option.value);
|
||||
const sourceInfo = metadata.sourceInfo;
|
||||
if (!sourceInfo) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -535,7 +796,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
return Array.from(sourceOptions.values()).sort((left, right) =>
|
||||
left.label.localeCompare(right.label, undefined, { sensitivity: 'base' })
|
||||
);
|
||||
}, [effectiveProviderId, modelOptions, recommendedOnly]);
|
||||
}, [effectiveProviderId, openCodeModelMetadata, recommendedOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedOpenCodeSourceIds.size === 0) {
|
||||
|
|
@ -588,6 +849,54 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
});
|
||||
};
|
||||
|
||||
const visibleOpenCodeModelMetadata = useMemo(() => {
|
||||
if (effectiveProviderId !== 'opencode') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const normalizedModelQuery = modelQuery.trim().toLowerCase();
|
||||
const matchesModelQuery = (metadata: OpenCodeModelOptionMetadata): boolean =>
|
||||
!normalizedModelQuery || metadata.searchText.includes(normalizedModelQuery);
|
||||
|
||||
const concreteOptions = openCodeModelMetadata
|
||||
.filter((metadata) => metadata.option.value.trim().length > 0)
|
||||
.filter((metadata) => !recommendedOnly || metadata.isRecommended)
|
||||
.filter((metadata) => {
|
||||
if (selectedOpenCodeSourceIds.size === 0) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(
|
||||
metadata.sourceInfo && selectedOpenCodeSourceIds.has(metadata.sourceInfo.id)
|
||||
);
|
||||
})
|
||||
.filter(matchesModelQuery)
|
||||
.sort((left, right) => {
|
||||
const recommendationOrder = compareTeamModelRecommendations(
|
||||
effectiveProviderId,
|
||||
left.option.value,
|
||||
right.option.value
|
||||
);
|
||||
return recommendationOrder || left.index - right.index;
|
||||
});
|
||||
|
||||
if (recommendedOnly) {
|
||||
return concreteOptions;
|
||||
}
|
||||
|
||||
return [
|
||||
...openCodeModelMetadata
|
||||
.filter((metadata) => metadata.option.value.trim().length === 0)
|
||||
.filter(matchesModelQuery),
|
||||
...concreteOptions,
|
||||
];
|
||||
}, [
|
||||
effectiveProviderId,
|
||||
modelQuery,
|
||||
openCodeModelMetadata,
|
||||
recommendedOnly,
|
||||
selectedOpenCodeSourceIds,
|
||||
]);
|
||||
|
||||
const visibleModelOptions = useMemo(() => {
|
||||
const normalizedModelQuery = modelQuery.trim().toLowerCase();
|
||||
const matchesModelQuery = (option: (typeof modelOptions)[number]): boolean => {
|
||||
|
|
@ -595,18 +904,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
return true;
|
||||
}
|
||||
const modelRecommendation = getTeamModelRecommendation(effectiveProviderId, option.value);
|
||||
const openCodePricingInfo = getOpenCodeModelPricingInfo(
|
||||
openCodeCatalogModelById.get(option.value)
|
||||
);
|
||||
return [
|
||||
option.value,
|
||||
option.label,
|
||||
option.badgeLabel ?? '',
|
||||
getOpenCodeSourceInfo(option.value)?.label ?? '',
|
||||
modelRecommendation?.label ?? '',
|
||||
modelRecommendation?.reason ?? '',
|
||||
openCodePricingInfo?.free ? 'free' : '',
|
||||
openCodePricingInfo?.summary ?? '',
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
|
|
@ -617,59 +920,21 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
return modelOptions.filter(matchesModelQuery);
|
||||
}
|
||||
|
||||
const concreteOptions = modelOptions
|
||||
.filter((option) => option.value.trim().length > 0)
|
||||
.map((option, index) => ({ option, index }))
|
||||
.filter(
|
||||
({ option }) =>
|
||||
!recommendedOnly || isTeamModelRecommended(effectiveProviderId, option.value)
|
||||
)
|
||||
.filter(({ option }) => {
|
||||
if (selectedOpenCodeSourceIds.size === 0) {
|
||||
return true;
|
||||
}
|
||||
const sourceInfo = getOpenCodeSourceInfo(option.value);
|
||||
return Boolean(sourceInfo && selectedOpenCodeSourceIds.has(sourceInfo.id));
|
||||
})
|
||||
.filter(({ option }) => matchesModelQuery(option))
|
||||
.sort((left, right) => {
|
||||
const recommendationOrder = compareTeamModelRecommendations(
|
||||
effectiveProviderId,
|
||||
left.option.value,
|
||||
right.option.value
|
||||
);
|
||||
return recommendationOrder || left.index - right.index;
|
||||
})
|
||||
.map(({ option }) => option);
|
||||
|
||||
if (recommendedOnly) {
|
||||
return concreteOptions;
|
||||
}
|
||||
|
||||
return [
|
||||
...modelOptions.filter((option) => option.value.trim().length === 0),
|
||||
...concreteOptions,
|
||||
].filter(matchesModelQuery);
|
||||
}, [
|
||||
effectiveProviderId,
|
||||
modelOptions,
|
||||
modelQuery,
|
||||
openCodeCatalogModelById,
|
||||
recommendedOnly,
|
||||
selectedOpenCodeSourceIds,
|
||||
]);
|
||||
return visibleOpenCodeModelMetadata.map((metadata) => metadata.option);
|
||||
}, [effectiveProviderId, modelOptions, modelQuery, visibleOpenCodeModelMetadata]);
|
||||
const visibleOpenCodeModelGroups = useMemo<OpenCodeModelGroup[]>(() => {
|
||||
if (effectiveProviderId !== 'opencode') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groups = new Map<string, OpenCodeModelGroup>();
|
||||
for (const option of visibleModelOptions) {
|
||||
for (const metadata of visibleOpenCodeModelMetadata) {
|
||||
const option = metadata.option;
|
||||
if (!option.value.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceInfo = getOpenCodeSourceInfo(option.value);
|
||||
const sourceInfo = metadata.sourceInfo;
|
||||
if (!sourceInfo) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -687,12 +952,19 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
}
|
||||
|
||||
return Array.from(groups.values());
|
||||
}, [effectiveProviderId, visibleModelOptions]);
|
||||
}, [effectiveProviderId, visibleOpenCodeModelMetadata]);
|
||||
const visibleDefaultModelOptions = visibleModelOptions.filter((option) => !option.value.trim());
|
||||
const visibleConcreteModelOptionCount =
|
||||
visibleModelOptions.length - visibleDefaultModelOptions.length;
|
||||
const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).length;
|
||||
const shouldShowModelSearch = concreteModelOptionCount > 8;
|
||||
const trimmedModelQuery = modelQuery.trim();
|
||||
const shouldConstrainModelListHeight = visibleModelOptions.length > 8;
|
||||
const shouldVirtualizeOpenCodeModels =
|
||||
effectiveProviderId === 'opencode' &&
|
||||
visibleConcreteModelOptionCount > OPENCODE_MODEL_VIRTUALIZATION_THRESHOLD;
|
||||
const getModelAdvisoryBadgeLabel = (reason: string | null): string =>
|
||||
reason?.toLowerCase().includes('ping not confirmed') ? 'Ping not confirmed' : 'Note';
|
||||
const renderModelOption = (opt: TeamRuntimeModelOption): React.JSX.Element => {
|
||||
const modelDisabledReason = getTeamModelUiDisabledReason(
|
||||
effectiveProviderId,
|
||||
|
|
@ -706,6 +978,8 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
opt.value !== '' && availabilityStatus === 'unavailable'
|
||||
? (availabilityReason ?? 'Unavailable in current runtime')
|
||||
: null;
|
||||
const modelAdvisoryReason =
|
||||
opt.value === '' ? null : (modelAdvisoryReasonByValue?.[opt.value] ?? null);
|
||||
const modelIssueReason =
|
||||
opt.value === '' ? null : (modelIssueReasonByValue?.[opt.value] ?? null);
|
||||
const modelUnavailableReason =
|
||||
|
|
@ -718,7 +992,8 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
runtimeProviderStatus
|
||||
) ??
|
||||
runtimeUnavailableReason);
|
||||
const hasModelIssue = Boolean(modelIssueReason || modelUnavailableReason);
|
||||
const hasBlockingModelIssue = Boolean(modelIssueReason || modelUnavailableReason);
|
||||
const hasModelAdvisory = Boolean(modelAdvisoryReason) && !hasBlockingModelIssue;
|
||||
const modelSelectable =
|
||||
activeProviderSelectable &&
|
||||
!modelUnavailableReason &&
|
||||
|
|
@ -727,14 +1002,17 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
const modelStatusMessage =
|
||||
modelUnavailableReason ??
|
||||
modelIssueReason ??
|
||||
modelAdvisoryReason ??
|
||||
modelDisabledReason ??
|
||||
availabilityReason ??
|
||||
null;
|
||||
const modelRecommendation = getTeamModelRecommendation(effectiveProviderId, opt.value);
|
||||
const openCodeMetadata =
|
||||
effectiveProviderId === 'opencode' ? openCodeModelMetadataByValue.get(opt.value) : null;
|
||||
const modelRecommendation =
|
||||
openCodeMetadata?.recommendation ??
|
||||
getTeamModelRecommendation(effectiveProviderId, opt.value);
|
||||
const openCodePricingInfo =
|
||||
effectiveProviderId === 'opencode'
|
||||
? getOpenCodeModelPricingInfo(openCodeCatalogModelById.get(opt.value))
|
||||
: null;
|
||||
effectiveProviderId === 'opencode' ? (openCodeMetadata?.pricingInfo ?? null) : null;
|
||||
const modelButtonTitle =
|
||||
modelStatusMessage ?? (opt.value === '' ? defaultModelTooltip : undefined);
|
||||
|
||||
|
|
@ -747,15 +1025,19 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
title={modelButtonTitle}
|
||||
className={cn(
|
||||
'flex min-h-[44px] items-center justify-center gap-1.5 rounded-md border bg-[var(--color-surface)] px-3 py-2 text-center text-xs font-medium transition-[background-color,border-color,color,box-shadow] duration-150',
|
||||
hasModelIssue && normalizedValue === opt.value
|
||||
hasBlockingModelIssue && normalizedValue === opt.value
|
||||
? 'border-red-500/60 bg-red-500/10 text-red-100 shadow-sm'
|
||||
: hasModelIssue
|
||||
: hasBlockingModelIssue
|
||||
? 'border-red-500/40 bg-red-500/5 text-red-200 hover:border-red-400/60 hover:bg-red-500/10 hover:text-red-100'
|
||||
: normalizedValue === opt.value
|
||||
? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: modelSelectable
|
||||
? 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-emphasis)] hover:bg-[color-mix(in_srgb,var(--color-surface-raised)_62%,var(--color-surface)_38%)] hover:text-[var(--color-text-secondary)] hover:shadow-sm'
|
||||
: 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)]',
|
||||
: hasModelAdvisory && normalizedValue === opt.value
|
||||
? 'border-amber-300/55 bg-amber-300/10 text-amber-100 shadow-sm'
|
||||
: hasModelAdvisory
|
||||
? 'border-amber-300/35 bg-amber-300/5 text-amber-200 hover:border-amber-300/55 hover:bg-amber-300/10 hover:text-amber-100'
|
||||
: normalizedValue === opt.value
|
||||
? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: modelSelectable
|
||||
? 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)] hover:border-[var(--color-border-emphasis)] hover:bg-[color-mix(in_srgb,var(--color-surface-raised)_62%,var(--color-surface)_38%)] hover:text-[var(--color-text-secondary)] hover:shadow-sm'
|
||||
: 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)]',
|
||||
!modelSelectable && 'cursor-not-allowed',
|
||||
!modelDisabledReason && !activeProviderSelectable && 'pointer-events-none'
|
||||
)}
|
||||
|
|
@ -833,7 +1115,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
</HoverTooltip>
|
||||
</span>
|
||||
) : null}
|
||||
{hasModelIssue && (
|
||||
{hasBlockingModelIssue ? (
|
||||
<span
|
||||
className="flex items-center justify-center gap-1 text-[10px] font-normal text-red-300"
|
||||
title={modelStatusMessage ?? undefined}
|
||||
|
|
@ -851,8 +1133,27 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
</HoverTooltip>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
{!hasModelIssue && modelDisabledReason && (
|
||||
) : null}
|
||||
{hasModelAdvisory ? (
|
||||
<span
|
||||
className="flex items-center justify-center gap-1 text-[10px] font-normal text-amber-200"
|
||||
title={modelStatusMessage ?? undefined}
|
||||
>
|
||||
<Info className="size-3 shrink-0" />
|
||||
<span>{getModelAdvisoryBadgeLabel(modelAdvisoryReason ?? null)}</span>
|
||||
{modelStatusMessage ? (
|
||||
<HoverTooltip
|
||||
content={modelStatusMessage}
|
||||
title={modelStatusMessage}
|
||||
stopClickPropagation
|
||||
contentClassName="max-w-[240px]"
|
||||
>
|
||||
<Info className="size-3 shrink-0 opacity-55 transition-opacity hover:opacity-85" />
|
||||
</HoverTooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
{!hasBlockingModelIssue && !hasModelAdvisory && modelDisabledReason && (
|
||||
<span
|
||||
className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]"
|
||||
title={modelDisabledReason}
|
||||
|
|
@ -1069,43 +1370,56 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
</div>
|
||||
) : null}
|
||||
{effectiveProviderId === 'opencode' ? (
|
||||
<div
|
||||
data-testid="team-model-selector-model-grid"
|
||||
className={cn(
|
||||
'space-y-3 rounded-md bg-[var(--color-surface)]',
|
||||
shouldConstrainModelListHeight && 'overflow-y-auto pr-1'
|
||||
)}
|
||||
style={{
|
||||
maxHeight: shouldConstrainModelListHeight ? 400 : undefined,
|
||||
}}
|
||||
>
|
||||
{visibleDefaultModelOptions.length > 0 ? (
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
|
||||
>
|
||||
{visibleDefaultModelOptions.map(renderModelOption)}
|
||||
</div>
|
||||
) : null}
|
||||
{visibleOpenCodeModelGroups.map((group) => (
|
||||
<section key={group.sourceId} data-testid="team-model-selector-opencode-group">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<h4 className="truncate text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-secondary)]">
|
||||
{group.sourceLabel}
|
||||
</h4>
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{group.options.length}
|
||||
</span>
|
||||
</div>
|
||||
shouldVirtualizeOpenCodeModels ? (
|
||||
<OpenCodeVirtualizedModelGrid
|
||||
defaultOptions={visibleDefaultModelOptions}
|
||||
groups={visibleOpenCodeModelGroups}
|
||||
renderModelOption={renderModelOption}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
data-testid="team-model-selector-model-grid"
|
||||
className={cn(
|
||||
'space-y-3 rounded-md bg-[var(--color-surface)]',
|
||||
shouldConstrainModelListHeight && 'overflow-y-auto pr-1'
|
||||
)}
|
||||
style={{
|
||||
maxHeight: shouldConstrainModelListHeight
|
||||
? OPENCODE_MODEL_GRID_MAX_HEIGHT_PX
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{visibleDefaultModelOptions.length > 0 ? (
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
|
||||
>
|
||||
{group.options.map(renderModelOption)}
|
||||
{visibleDefaultModelOptions.map(renderModelOption)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{visibleOpenCodeModelGroups.map((group) => (
|
||||
<section
|
||||
key={group.sourceId}
|
||||
data-testid="team-model-selector-opencode-group"
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<h4 className="truncate text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-secondary)]">
|
||||
{group.sourceLabel}
|
||||
</h4>
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
{group.options.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="grid gap-1.5"
|
||||
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
|
||||
>
|
||||
{group.options.map(renderModelOption)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
data-testid="team-model-selector-model-grid"
|
||||
|
|
@ -1115,7 +1429,9 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
|
|||
)}
|
||||
style={{
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
maxHeight: shouldConstrainModelListHeight ? 400 : undefined,
|
||||
maxHeight: shouldConstrainModelListHeight
|
||||
? OPENCODE_MODEL_GRID_MAX_HEIGHT_PX
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{visibleModelOptions.map(renderModelOption)}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { getDefaultProviderBackendId } from '@renderer/utils/providerBackendIden
|
|||
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { ResolvedTeamMember, TeamCreateRequest, TeamProviderId } from '@shared/types';
|
||||
|
|
@ -107,14 +108,17 @@ export function resolveLaunchDialogPrefill({
|
|||
savedRequest?.fastMode ?? previousLaunchParams?.fastMode ?? storedFastMode ?? 'inherit';
|
||||
const limitContext =
|
||||
previousLaunchParams?.limitContext ?? savedRequest?.limitContext ?? storedLimitContext;
|
||||
const providerBackendId =
|
||||
migrateProviderBackendId(
|
||||
providerId,
|
||||
previousLaunchParams?.providerBackendId?.trim() || savedRequest?.providerBackendId?.trim()
|
||||
) ??
|
||||
getDefaultProviderBackendId(providerId) ??
|
||||
undefined;
|
||||
|
||||
return {
|
||||
providerId,
|
||||
providerBackendId:
|
||||
previousLaunchParams?.providerBackendId?.trim() ||
|
||||
savedRequest?.providerBackendId?.trim() ||
|
||||
getDefaultProviderBackendId(providerId) ||
|
||||
undefined,
|
||||
providerBackendId,
|
||||
model: matchingModel
|
||||
? normalizeExplicitTeamModelForUi(providerId, matchingModel)
|
||||
: getStoredModel(providerId),
|
||||
|
|
|
|||
|
|
@ -55,6 +55,24 @@ export function buildReusableProviderPrepareModelResults(
|
|||
);
|
||||
}
|
||||
|
||||
export function mergeReusableProviderPrepareModelResults(
|
||||
existingModelResultsById:
|
||||
| Record<string, ProviderPrepareDiagnosticsModelResult>
|
||||
| null
|
||||
| undefined,
|
||||
modelResultsById: Record<string, ProviderPrepareDiagnosticsModelResult>
|
||||
): Record<string, ProviderPrepareDiagnosticsModelResult> {
|
||||
const mergedModelResultsById = { ...(existingModelResultsById ?? {}) };
|
||||
for (const [modelId, result] of Object.entries(modelResultsById)) {
|
||||
if (result.status === 'notes') {
|
||||
delete mergedModelResultsById[modelId];
|
||||
continue;
|
||||
}
|
||||
mergedModelResultsById[modelId] = result;
|
||||
}
|
||||
return mergedModelResultsById;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
|
@ -315,6 +333,7 @@ function createOpenCodeAdvisoryDeepVerificationModelResult(
|
|||
): ProviderPrepareDiagnosticsModelResult {
|
||||
const line = `${getModelLabel(providerId, modelId)} - ping not confirmed`;
|
||||
return {
|
||||
// TODO: Introduce a dedicated `unconfirmed` model result status for deep-ping advisory results.
|
||||
status: 'notes',
|
||||
line,
|
||||
warningLine: line,
|
||||
|
|
|
|||
|
|
@ -56,11 +56,13 @@ export function getShortLivedProviderPrepareModelIssueReasons({
|
|||
providerId: TeamProviderId;
|
||||
cacheKey: string;
|
||||
}): {
|
||||
modelAdvisoryReasonByValue: Record<string, string>;
|
||||
modelIssueReasonByValue: Record<string, string>;
|
||||
modelUnavailableReasonByValue: Record<string, string>;
|
||||
} {
|
||||
if (providerId !== 'opencode') {
|
||||
return {
|
||||
modelAdvisoryReasonByValue: {},
|
||||
modelIssueReasonByValue: {},
|
||||
modelUnavailableReasonByValue: {},
|
||||
};
|
||||
|
|
@ -71,11 +73,13 @@ export function getShortLivedProviderPrepareModelIssueReasons({
|
|||
const entry = shortLivedProviderPrepareIssueCache.get(cacheKey);
|
||||
if (!entry) {
|
||||
return {
|
||||
modelAdvisoryReasonByValue: {},
|
||||
modelIssueReasonByValue: {},
|
||||
modelUnavailableReasonByValue: {},
|
||||
};
|
||||
}
|
||||
|
||||
const modelAdvisoryReasonByValue: Record<string, string> = {};
|
||||
const modelIssueReasonByValue: Record<string, string> = {};
|
||||
const modelUnavailableReasonByValue: Record<string, string> = {};
|
||||
for (const [modelId, result] of Object.entries(entry.modelResultsById)) {
|
||||
|
|
@ -86,11 +90,12 @@ export function getShortLivedProviderPrepareModelIssueReasons({
|
|||
if (result.status === 'failed') {
|
||||
modelUnavailableReasonByValue[modelId] = reason;
|
||||
} else if (result.status === 'notes') {
|
||||
modelIssueReasonByValue[modelId] = reason;
|
||||
modelAdvisoryReasonByValue[modelId] = reason;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modelAdvisoryReasonByValue,
|
||||
modelIssueReasonByValue,
|
||||
modelUnavailableReasonByValue,
|
||||
};
|
||||
|
|
@ -132,6 +137,22 @@ export function storeShortLivedProviderPrepareModelResults({
|
|||
}
|
||||
|
||||
if (Object.keys(issueResultsById).length > 0) {
|
||||
const existingEntry = shortLivedProviderPrepareCache.get(cacheKey);
|
||||
if (existingEntry) {
|
||||
const nextReadyResultsById = { ...existingEntry.modelResultsById };
|
||||
for (const modelId of Object.keys(issueResultsById)) {
|
||||
delete nextReadyResultsById[modelId];
|
||||
}
|
||||
if (Object.keys(nextReadyResultsById).length > 0) {
|
||||
shortLivedProviderPrepareCache.set(cacheKey, {
|
||||
expiresAt: existingEntry.expiresAt,
|
||||
modelResultsById: nextReadyResultsById,
|
||||
});
|
||||
} else {
|
||||
shortLivedProviderPrepareCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
const existingIssueEntry = shortLivedProviderPrepareIssueCache.get(cacheKey);
|
||||
const nextIssueResultsById = {
|
||||
...(existingIssueEntry?.modelResultsById ?? {}),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ interface LeadModelRowProps {
|
|||
warningText?: string | null;
|
||||
disableGeminiOption?: boolean;
|
||||
modelIssueText?: string | null;
|
||||
modelAdvisoryReasonByValue?: Partial<Record<string, string | null | undefined>>;
|
||||
modelIssueReasonByValue?: Partial<Record<string, string | null | undefined>>;
|
||||
modelUnavailableReasonByValue?: Partial<Record<string, string | null | undefined>>;
|
||||
showAnthropicContextLimit?: boolean;
|
||||
|
|
@ -66,6 +67,7 @@ export const LeadModelRow = ({
|
|||
warningText,
|
||||
disableGeminiOption = false,
|
||||
modelIssueText,
|
||||
modelAdvisoryReasonByValue,
|
||||
modelIssueReasonByValue,
|
||||
modelUnavailableReasonByValue,
|
||||
showAnthropicContextLimit = providerId === 'anthropic',
|
||||
|
|
@ -86,9 +88,15 @@ export const LeadModelRow = ({
|
|||
model.trim() && modelUnavailableReasonByValue?.[model.trim()]
|
||||
? modelUnavailableReasonByValue[model.trim()]
|
||||
: null;
|
||||
const selectedModelAdvisoryText =
|
||||
model.trim() && modelAdvisoryReasonByValue?.[model.trim()]
|
||||
? modelAdvisoryReasonByValue[model.trim()]
|
||||
: null;
|
||||
const currentModelIssueText =
|
||||
modelIssueText ?? selectedModelUnavailableText ?? selectedModelIssueText ?? null;
|
||||
const currentModelAdvisoryText = currentModelIssueText ? null : selectedModelAdvisoryText;
|
||||
const hasModelIssue = Boolean(currentModelIssueText);
|
||||
const hasModelAdvisory = Boolean(currentModelAdvisoryText);
|
||||
const showSonnetExtraUsageWarning =
|
||||
providerId === 'anthropic' &&
|
||||
!limitContext &&
|
||||
|
|
@ -155,7 +163,9 @@ export const LeadModelRow = ({
|
|||
className={cn(
|
||||
'h-8 w-full justify-start gap-1 overflow-hidden text-left',
|
||||
hasModelIssue &&
|
||||
'border-red-500/50 bg-red-500/10 text-red-100 hover:border-red-400/60 hover:bg-red-500/15 hover:text-red-50'
|
||||
'border-red-500/50 bg-red-500/10 text-red-100 hover:border-red-400/60 hover:bg-red-500/15 hover:text-red-50',
|
||||
hasModelAdvisory &&
|
||||
'border-amber-300/45 bg-amber-300/10 text-amber-100 hover:border-amber-300/60 hover:bg-amber-300/15 hover:text-amber-50'
|
||||
)}
|
||||
aria-label={modelButtonAriaLabel}
|
||||
onClick={() => setModelExpanded((prev) => !prev)}
|
||||
|
|
@ -168,6 +178,7 @@ export const LeadModelRow = ({
|
|||
<ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{modelButtonLabel}</span>
|
||||
{hasModelIssue ? <AlertTriangle className="size-3.5 shrink-0 text-red-300" /> : null}
|
||||
{hasModelAdvisory ? <Info className="size-3.5 shrink-0 text-amber-300" /> : null}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -193,6 +204,7 @@ export const LeadModelRow = ({
|
|||
onValueChange={onModelChange}
|
||||
id="lead-model"
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelAdvisoryReasonByValue={modelAdvisoryReasonByValue}
|
||||
modelIssueReasonByValue={{
|
||||
...(modelIssueReasonByValue ?? {}),
|
||||
...(model.trim() && modelIssueText ? { [model.trim()]: modelIssueText } : {}),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
|
|
@ -47,6 +48,7 @@ import type {
|
|||
interface MemberCardProps {
|
||||
member: ResolvedTeamMember;
|
||||
memberColor: string;
|
||||
fullBleedSurface?: boolean;
|
||||
runtimeSummary?: string;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
runtimeRunId?: string | null;
|
||||
|
|
@ -78,6 +80,8 @@ interface MemberCardProps {
|
|||
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const MEMBER_ROW_SURFACE_BLEED_CLASS = '-mx-[calc(1rem-5px)] px-[calc(1rem-5px)]';
|
||||
|
||||
function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
||||
summary: string | undefined;
|
||||
memory: string | undefined;
|
||||
|
|
@ -113,6 +117,7 @@ function getLaunchFailureLinkLabel(url: string): string {
|
|||
export const MemberCard = memo(function MemberCard({
|
||||
member,
|
||||
memberColor,
|
||||
fullBleedSurface = true,
|
||||
runtimeSummary,
|
||||
runtimeEntry,
|
||||
runtimeRunId,
|
||||
|
|
@ -232,6 +237,8 @@ export const MemberCard = memo(function MemberCard({
|
|||
spawnLaunchState !== 'failed_to_start' &&
|
||||
!activityTask &&
|
||||
!runtimeSummary;
|
||||
const usesLaunchSkeletonSurface = spawnCardClass.includes('member-waiting-shimmer');
|
||||
const rowSurfaceBleedClass = fullBleedSurface ? MEMBER_ROW_SURFACE_BLEED_CLASS : undefined;
|
||||
const showLaunchBadge =
|
||||
!isRemoved &&
|
||||
!runtimeAdvisoryLabel &&
|
||||
|
|
@ -359,10 +366,15 @@ export const MemberCard = memo(function MemberCard({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`rounded transition-opacity duration-300 ${isRemoved ? 'opacity-50' : ''} ${spawnCardClass}`}
|
||||
className={cn(
|
||||
'rounded transition-opacity duration-300',
|
||||
usesLaunchSkeletonSurface && rowSurfaceBleedClass,
|
||||
isRemoved && 'opacity-50',
|
||||
spawnCardClass
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="group relative cursor-pointer rounded py-1.5"
|
||||
className={cn('group relative cursor-pointer rounded py-1.5', rowSurfaceBleedClass)}
|
||||
style={undefined}
|
||||
title={activityTitle}
|
||||
role="button"
|
||||
|
|
|
|||
|
|
@ -78,6 +78,9 @@ interface MemberDraftRowProps {
|
|||
infoText?: string | null;
|
||||
disableGeminiOption?: boolean;
|
||||
modelIssueText?: string | null;
|
||||
modelAdvisoryReasonByProvider?: Partial<
|
||||
Record<TeamProviderId, Partial<Record<string, string | null | undefined>>>
|
||||
>;
|
||||
modelIssueReasonByProvider?: Partial<
|
||||
Record<TeamProviderId, Partial<Record<string, string | null | undefined>>>
|
||||
>;
|
||||
|
|
@ -134,6 +137,7 @@ export const MemberDraftRow = ({
|
|||
infoText,
|
||||
disableGeminiOption = false,
|
||||
modelIssueText,
|
||||
modelAdvisoryReasonByProvider,
|
||||
modelIssueReasonByProvider,
|
||||
modelUnavailableReasonByProvider,
|
||||
showWorktreeIsolationControls = false,
|
||||
|
|
@ -251,27 +255,41 @@ export const MemberDraftRow = ({
|
|||
modelUnavailableReasonByProvider?.[effectiveProviderId]?.[effectiveModelKey]
|
||||
? modelUnavailableReasonByProvider[effectiveProviderId]?.[effectiveModelKey]
|
||||
: null;
|
||||
const selectedModelAdvisoryText =
|
||||
effectiveModelKey && modelAdvisoryReasonByProvider?.[effectiveProviderId]?.[effectiveModelKey]
|
||||
? modelAdvisoryReasonByProvider[effectiveProviderId]?.[effectiveModelKey]
|
||||
: null;
|
||||
const currentModelIssueText =
|
||||
modelIssueText ?? selectedModelUnavailableText ?? selectedModelIssueText ?? null;
|
||||
const currentModelAdvisoryText = currentModelIssueText ? null : selectedModelAdvisoryText;
|
||||
const hasModelIssue = Boolean(currentModelIssueText);
|
||||
const hasModelAdvisory = Boolean(currentModelAdvisoryText);
|
||||
const modelButtonDisabled = (lockProviderModel && !canOpenLockedModelPanel) || isRemoved;
|
||||
const modelButtonTitle =
|
||||
[currentModelIssueText, modelTooltipText]
|
||||
[currentModelIssueText ?? currentModelAdvisoryText, modelTooltipText]
|
||||
.filter((message): message is string => Boolean(message))
|
||||
.join('\n') || undefined;
|
||||
const modelIssueDescriptionId = hasModelIssue ? `member-${member.id}-model-issue` : undefined;
|
||||
const modelIssueDescriptionId =
|
||||
hasModelIssue || hasModelAdvisory ? `member-${member.id}-model-issue` : undefined;
|
||||
const modelHelpDescriptionId = modelTooltipText ? `member-${member.id}-model-help` : undefined;
|
||||
const modelButtonDescribedBy =
|
||||
[modelIssueDescriptionId, modelHelpDescriptionId].filter(Boolean).join(' ') || undefined;
|
||||
const modelButtonTooltipContent =
|
||||
currentModelIssueText || modelTooltipText ? (
|
||||
currentModelIssueText || currentModelAdvisoryText || modelTooltipText ? (
|
||||
<>
|
||||
{currentModelIssueText ? (
|
||||
<span className="block text-red-300">{currentModelIssueText}</span>
|
||||
) : null}
|
||||
{currentModelAdvisoryText ? (
|
||||
<span className="block text-amber-200">{currentModelAdvisoryText}</span>
|
||||
) : null}
|
||||
{modelTooltipText ? (
|
||||
<span
|
||||
className={cn('block', currentModelIssueText && 'mt-1 border-t border-white/10 pt-1')}
|
||||
className={cn(
|
||||
'block',
|
||||
(currentModelIssueText || currentModelAdvisoryText) &&
|
||||
'mt-1 border-t border-white/10 pt-1'
|
||||
)}
|
||||
>
|
||||
{modelTooltipText}
|
||||
</span>
|
||||
|
|
@ -386,7 +404,9 @@ export const MemberDraftRow = ({
|
|||
className={cn(
|
||||
'h-8 w-full justify-start gap-1 overflow-hidden text-left',
|
||||
hasModelIssue &&
|
||||
'border-red-500/50 bg-red-500/10 text-red-100 hover:border-red-400/60 hover:bg-red-500/15 hover:text-red-50'
|
||||
'border-red-500/50 bg-red-500/10 text-red-100 hover:border-red-400/60 hover:bg-red-500/15 hover:text-red-50',
|
||||
hasModelAdvisory &&
|
||||
'border-amber-300/45 bg-amber-300/10 text-amber-100 hover:border-amber-300/60 hover:bg-amber-300/15 hover:text-amber-50'
|
||||
)}
|
||||
aria-label={modelButtonAriaLabel}
|
||||
aria-describedby={modelButtonDescribedBy}
|
||||
|
|
@ -403,6 +423,7 @@ export const MemberDraftRow = ({
|
|||
{hasModelIssue ? (
|
||||
<AlertTriangle className="size-3.5 shrink-0 text-red-300" />
|
||||
) : null}
|
||||
{hasModelAdvisory ? <Info className="size-3.5 shrink-0 text-amber-300" /> : null}
|
||||
</Button>
|
||||
</HoverTooltip>
|
||||
{modelTooltipText ? (
|
||||
|
|
@ -410,13 +431,20 @@ export const MemberDraftRow = ({
|
|||
{modelTooltipText}
|
||||
</span>
|
||||
) : null}
|
||||
{currentModelIssueText ? (
|
||||
{currentModelIssueText || currentModelAdvisoryText ? (
|
||||
<p
|
||||
id={modelIssueDescriptionId}
|
||||
className="flex items-start gap-1 text-[10px] leading-snug text-red-300"
|
||||
className={cn(
|
||||
'flex items-start gap-1 text-[10px] leading-snug',
|
||||
currentModelIssueText ? 'text-red-300' : 'text-amber-200'
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||
<span>{currentModelIssueText}</span>
|
||||
{currentModelIssueText ? (
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||
) : (
|
||||
<Info className="mt-0.5 size-3 shrink-0" />
|
||||
)}
|
||||
<span>{currentModelIssueText ?? currentModelAdvisoryText}</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -585,6 +613,7 @@ export const MemberDraftRow = ({
|
|||
}}
|
||||
id={`member-${member.id}-model`}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelAdvisoryReasonByValue={modelAdvisoryReasonByProvider?.[effectiveProviderId]}
|
||||
modelIssueReasonByValue={{
|
||||
...(modelIssueReasonByProvider?.[effectiveProviderId] ?? {}),
|
||||
...(effectiveModelKey && modelIssueText
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import {
|
||||
deriveReviewActivityTimerAnchor,
|
||||
deriveWorkActivityTimerAnchor,
|
||||
|
|
@ -38,6 +39,7 @@ interface MemberListProps {
|
|||
memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>;
|
||||
runtimeRunId?: string | null;
|
||||
isLaunchSettling?: boolean;
|
||||
isRosterLoading?: boolean;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
leadActivity?: LeadActivityState;
|
||||
|
|
@ -321,6 +323,7 @@ function areMemberListPropsEqual(
|
|||
areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) &&
|
||||
prev.runtimeRunId === next.runtimeRunId &&
|
||||
prev.isLaunchSettling === next.isLaunchSettling &&
|
||||
prev.isRosterLoading === next.isRosterLoading &&
|
||||
prev.isTeamAlive === next.isTeamAlive &&
|
||||
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
||||
prev.leadActivity === next.leadActivity &&
|
||||
|
|
@ -338,6 +341,7 @@ interface MemberCardRowProps {
|
|||
member: ResolvedTeamMember;
|
||||
isRemoved: boolean;
|
||||
memberColor: string;
|
||||
fullBleedSurface: boolean;
|
||||
currentTask: TeamTaskWithKanban | null;
|
||||
reviewTask: TeamTaskWithKanban | null;
|
||||
currentTaskTimer: MemberActivityTimerAnchor | null;
|
||||
|
|
@ -371,6 +375,7 @@ const MemberCardRow = memo(function MemberCardRow({
|
|||
member,
|
||||
isRemoved,
|
||||
memberColor,
|
||||
fullBleedSurface,
|
||||
currentTask,
|
||||
reviewTask,
|
||||
currentTaskTimer,
|
||||
|
|
@ -418,6 +423,7 @@ const MemberCardRow = memo(function MemberCardRow({
|
|||
<MemberCard
|
||||
member={member}
|
||||
memberColor={memberColor}
|
||||
fullBleedSurface={fullBleedSurface}
|
||||
taskCounts={taskCounts}
|
||||
isTeamAlive={isTeamAlive}
|
||||
isTeamProvisioning={isTeamProvisioning}
|
||||
|
|
@ -466,6 +472,7 @@ const MemberListLoadingSkeleton = ({
|
|||
expectedTeammateCount?: number;
|
||||
}>): React.JSX.Element => {
|
||||
const skeletonCount = getMemberLoadingSkeletonCount(expectedTeammateCount);
|
||||
const { isLight } = useTheme();
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -477,14 +484,17 @@ const MemberListLoadingSkeleton = ({
|
|||
{Array.from({ length: skeletonCount }, (_, index) => {
|
||||
const accent = MEMBER_LOADING_ACCENTS[index] ?? MEMBER_LOADING_ACCENTS[0];
|
||||
return (
|
||||
<div key={index} className="flex min-h-[52px] min-w-0 items-center gap-3">
|
||||
<div className="relative size-7 shrink-0">
|
||||
<div key={index} className="flex min-h-[52px] min-w-0 items-center gap-2.5">
|
||||
<div className="relative size-[34px] shrink-0">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full border-2 bg-[var(--color-surface-raised)]"
|
||||
style={{ borderColor: accent }}
|
||||
style={{
|
||||
borderColor: accent,
|
||||
boxShadow: isLight ? 'none' : `0 0 0 1px ${accent}26`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 right-0 size-2 rounded-full border border-[var(--color-surface)]"
|
||||
className="absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)]"
|
||||
style={{ backgroundColor: accent }}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -523,6 +533,26 @@ const MemberListLoadingSkeleton = ({
|
|||
);
|
||||
};
|
||||
|
||||
const MemberRosterUnavailableState = ({
|
||||
expectedTeammateCount,
|
||||
}: Readonly<{
|
||||
expectedTeammateCount?: number;
|
||||
}>): React.JSX.Element => {
|
||||
const count = Number.isFinite(expectedTeammateCount)
|
||||
? Math.max(0, Math.floor(expectedTeammateCount ?? 0))
|
||||
: 0;
|
||||
const teammateLabel = count === 1 ? '1 teammate is' : `${count || 'Some'} teammates are`;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface-sidebar)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||
<div className="font-medium text-[var(--color-text)]">Member roster unavailable</div>
|
||||
<div className="mt-1 text-xs">
|
||||
{teammateLabel} known from team metadata, but roster details are missing.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MemberList = memo(function MemberList({
|
||||
teamName = '__unknown_team__',
|
||||
members,
|
||||
|
|
@ -534,6 +564,7 @@ export const MemberList = memo(function MemberList({
|
|||
memberRuntimeEntries,
|
||||
runtimeRunId,
|
||||
isLaunchSettling,
|
||||
isRosterLoading,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
|
|
@ -724,13 +755,20 @@ export const MemberList = memo(function MemberList({
|
|||
);
|
||||
|
||||
const expectsTeammates = (expectedTeammateCount ?? 0) > 0;
|
||||
const canStillHydrateExpectedTeammates =
|
||||
Boolean(isRosterLoading || isTeamProvisioning) ||
|
||||
(isTeamAlive !== false && Boolean(isLaunchSettling));
|
||||
const shouldShowExpectedTeammateSkeleton = expectsTeammates && canStillHydrateExpectedTeammates;
|
||||
const hasOnlyLeadWhileTeammatesLoad =
|
||||
expectsTeammates && activeTeammateCount === 0 && removedMembers.length === 0;
|
||||
shouldShowExpectedTeammateSkeleton && activeTeammateCount === 0 && removedMembers.length === 0;
|
||||
|
||||
if (members.length === 0 || hasOnlyLeadWhileTeammatesLoad) {
|
||||
if (expectsTeammates) {
|
||||
if (members.length === 0) {
|
||||
if (shouldShowExpectedTeammateSkeleton) {
|
||||
return <MemberListLoadingSkeleton expectedTeammateCount={expectedTeammateCount} />;
|
||||
}
|
||||
if (expectsTeammates) {
|
||||
return <MemberRosterUnavailableState expectedTeammateCount={expectedTeammateCount} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||
|
|
@ -739,6 +777,10 @@ export const MemberList = memo(function MemberList({
|
|||
);
|
||||
}
|
||||
|
||||
if (hasOnlyLeadWhileTeammatesLoad) {
|
||||
return <MemberListLoadingSkeleton expectedTeammateCount={expectedTeammateCount} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-1">
|
||||
<div className={gridClass}>
|
||||
|
|
@ -787,6 +829,7 @@ export const MemberList = memo(function MemberList({
|
|||
member={member}
|
||||
isRemoved={false}
|
||||
memberColor={colorMap.get(member.name) ?? 'blue'}
|
||||
fullBleedSurface={!isWide}
|
||||
currentTask={currentTask}
|
||||
reviewTask={reviewTask}
|
||||
currentTaskTimer={currentTaskTimer}
|
||||
|
|
@ -832,6 +875,7 @@ export const MemberList = memo(function MemberList({
|
|||
member={member}
|
||||
isRemoved={true}
|
||||
memberColor={colorMap.get(member.name) ?? 'blue'}
|
||||
fullBleedSurface={!isWide}
|
||||
currentTask={null}
|
||||
reviewTask={null}
|
||||
currentTaskTimer={null}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRole
|
|||
import { cn } from '@renderer/lib/utils';
|
||||
import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { GitBranch, Plus } from 'lucide-react';
|
||||
|
||||
|
|
@ -115,6 +116,9 @@ export interface MembersEditorSectionProps {
|
|||
memberInfoById?: Record<string, string | null | undefined>;
|
||||
disableGeminiOption?: boolean;
|
||||
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||
modelAdvisoryReasonByProvider?: Partial<
|
||||
Record<TeamProviderId, Partial<Record<string, string | null | undefined>>>
|
||||
>;
|
||||
modelIssueReasonByProvider?: Partial<
|
||||
Record<TeamProviderId, Partial<Record<string, string | null | undefined>>>
|
||||
>;
|
||||
|
|
@ -160,6 +164,7 @@ export const MembersEditorSection = ({
|
|||
memberInfoById,
|
||||
disableGeminiOption = false,
|
||||
memberModelIssueById,
|
||||
modelAdvisoryReasonByProvider,
|
||||
modelIssueReasonByProvider,
|
||||
modelUnavailableReasonByProvider,
|
||||
disableAddMember = false,
|
||||
|
|
@ -232,11 +237,18 @@ export const MembersEditorSection = ({
|
|||
onChange(
|
||||
members.map((c) =>
|
||||
c.id === memberId
|
||||
? {
|
||||
...c,
|
||||
providerId,
|
||||
model: c.providerId === providerId ? c.model : '',
|
||||
}
|
||||
? (() => {
|
||||
const previousProviderId = c.providerId ?? inheritedProviderId;
|
||||
const providerChanged = previousProviderId !== providerId;
|
||||
return {
|
||||
...c,
|
||||
providerId,
|
||||
providerBackendId: migrateProviderBackendId(providerId, c.providerBackendId),
|
||||
model: providerChanged ? '' : c.model,
|
||||
effort: providerChanged ? undefined : c.effort,
|
||||
fastMode: providerChanged ? undefined : c.fastMode,
|
||||
};
|
||||
})()
|
||||
: c
|
||||
)
|
||||
);
|
||||
|
|
@ -444,6 +456,7 @@ export const MembersEditorSection = ({
|
|||
infoText={memberInfoById?.[member.id] ?? null}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueText={memberModelIssueById?.[member.id] ?? null}
|
||||
modelAdvisoryReasonByProvider={modelAdvisoryReasonByProvider}
|
||||
modelIssueReasonByProvider={modelIssueReasonByProvider}
|
||||
modelUnavailableReasonByProvider={modelUnavailableReasonByProvider}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ interface TeamRosterEditorSectionProps {
|
|||
disableGeminiOption?: boolean;
|
||||
leadModelIssueText?: string | null;
|
||||
memberModelIssueById?: Record<string, string | null | undefined>;
|
||||
modelAdvisoryReasonByProvider?: Partial<
|
||||
Record<TeamProviderId, Partial<Record<string, string | null | undefined>>>
|
||||
>;
|
||||
modelIssueReasonByProvider?: Partial<
|
||||
Record<TeamProviderId, Partial<Record<string, string | null | undefined>>>
|
||||
>;
|
||||
|
|
@ -101,6 +104,7 @@ const TeamRosterEditorSectionImpl = ({
|
|||
disableGeminiOption = false,
|
||||
leadModelIssueText,
|
||||
memberModelIssueById,
|
||||
modelAdvisoryReasonByProvider,
|
||||
modelIssueReasonByProvider,
|
||||
modelUnavailableReasonByProvider,
|
||||
showWorktreeIsolationControls = false,
|
||||
|
|
@ -161,6 +165,7 @@ const TeamRosterEditorSectionImpl = ({
|
|||
softDeleteMembers={softDeleteMembers}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
modelAdvisoryReasonByProvider={modelAdvisoryReasonByProvider}
|
||||
modelIssueReasonByProvider={modelIssueReasonByProvider}
|
||||
modelUnavailableReasonByProvider={modelUnavailableReasonByProvider}
|
||||
showWorktreeIsolationControls={showWorktreeIsolationControls}
|
||||
|
|
@ -184,6 +189,7 @@ const TeamRosterEditorSectionImpl = ({
|
|||
warningText={leadWarningText}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueText={leadModelIssueText}
|
||||
modelAdvisoryReasonByValue={modelAdvisoryReasonByProvider?.[providerId]}
|
||||
modelIssueReasonByValue={modelIssueReasonByProvider?.[providerId]}
|
||||
modelUnavailableReasonByValue={modelUnavailableReasonByProvider?.[providerId]}
|
||||
showAnthropicContextLimit={hasAnthropicRuntime}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,15 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRole
|
|||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
|
||||
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
|
||||
import { isTeamEffortLevel } from '@shared/utils/effortLevels';
|
||||
import { isTeamEffortLevel, isTeamEffortLevelForProvider } from '@shared/utils/effortLevels';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
import { validateTeamMemberNameFormat } from '@shared/utils/teamMemberName';
|
||||
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import {
|
||||
inferTeamProviderIdFromModel,
|
||||
normalizeOptionalTeamProviderId,
|
||||
} from '@shared/utils/teamProvider';
|
||||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
|
|
@ -150,6 +154,44 @@ function normalizeDraftEffort(value: string | undefined): EffortLevel | undefine
|
|||
return isTeamEffortLevel(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeDraftEffortForProvider(
|
||||
value: string | undefined,
|
||||
providerId: TeamProviderId | undefined
|
||||
): EffortLevel | undefined {
|
||||
if (!providerId) {
|
||||
return normalizeDraftEffort(value);
|
||||
}
|
||||
return isTeamEffortLevelForProvider(value, providerId) ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeDraftModelForProvider(
|
||||
value: string | undefined,
|
||||
providerId: TeamProviderId | undefined
|
||||
): string | undefined {
|
||||
const normalized = normalizeExplicitTeamModelForUi(providerId, value?.trim() ?? '');
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const inferredProviderId =
|
||||
inferTeamProviderIdFromModel(normalized) ?? inferTeamProviderIdFromModel(value);
|
||||
if (providerId && inferredProviderId && inferredProviderId !== providerId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeDraftProviderBackendForProvider(
|
||||
value: TeamProviderBackendId | undefined,
|
||||
providerId: TeamProviderId | undefined
|
||||
): TeamProviderBackendId | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
return providerId ? migrateProviderBackendId(providerId, value) : value;
|
||||
}
|
||||
|
||||
interface ExistingMemberColorInput {
|
||||
name: string;
|
||||
color?: string;
|
||||
|
|
@ -248,7 +290,10 @@ export function getWorkflowForExport(member: MemberDraft): string | undefined {
|
|||
return chips.length > 0 ? serializeChipsWithText(workflowRaw, chips) : workflowRaw;
|
||||
}
|
||||
|
||||
export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioningMemberInput[] {
|
||||
export function buildMembersFromDrafts(
|
||||
members: MemberDraft[],
|
||||
options?: { inheritedProviderId?: TeamProviderId }
|
||||
): TeamProvisioningMemberInput[] {
|
||||
return members
|
||||
.map((member) => {
|
||||
if (member.removedAt) {
|
||||
|
|
@ -268,14 +313,27 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
|
|||
if (providerId) {
|
||||
result.providerId = providerId;
|
||||
}
|
||||
if (member.providerBackendId) {
|
||||
result.providerBackendId = member.providerBackendId;
|
||||
const providerBackendId = normalizeDraftProviderBackendForProvider(
|
||||
member.providerBackendId,
|
||||
providerId ?? options?.inheritedProviderId
|
||||
);
|
||||
if (providerBackendId) {
|
||||
result.providerBackendId = providerBackendId;
|
||||
}
|
||||
const model = member.model?.trim();
|
||||
if (model) {
|
||||
result.model = normalizeExplicitTeamModelForUi(providerId, model);
|
||||
const normalizedModel = normalizeDraftModelForProvider(
|
||||
model,
|
||||
providerId ?? options?.inheritedProviderId
|
||||
);
|
||||
if (normalizedModel) {
|
||||
result.model = normalizedModel;
|
||||
}
|
||||
}
|
||||
const effort = normalizeDraftEffort(member.effort);
|
||||
const effort = normalizeDraftEffortForProvider(
|
||||
member.effort,
|
||||
providerId ?? options?.inheritedProviderId
|
||||
);
|
||||
if (effort) {
|
||||
result.effort = effort;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ interface MessageComposerProps {
|
|||
teamName: string;
|
||||
members: ResolvedTeamMember[];
|
||||
layout?: 'default' | 'compact';
|
||||
widthMode?: 'full' | 'floating-adaptive';
|
||||
isTeamAlive?: boolean;
|
||||
sending: boolean;
|
||||
sendError: string | null;
|
||||
|
|
@ -95,6 +96,9 @@ interface PendingSendState {
|
|||
}
|
||||
|
||||
let pendingSendIdCounter = 0;
|
||||
const FLOATING_COMPOSER_MIN_WIDTH = 350;
|
||||
const FLOATING_COMPOSER_MAX_WIDTH = 500;
|
||||
const FLOATING_COMPOSER_TEXT_BUFFER = 4;
|
||||
|
||||
function createPendingSendId(): string {
|
||||
const randomId = globalThis.crypto?.randomUUID?.();
|
||||
|
|
@ -107,6 +111,7 @@ export const MessageComposer = ({
|
|||
teamName,
|
||||
members,
|
||||
layout = 'default',
|
||||
widthMode = 'full',
|
||||
isTeamAlive,
|
||||
sending,
|
||||
sendError,
|
||||
|
|
@ -653,6 +658,68 @@ export const MessageComposer = ({
|
|||
draft.attachments.length > 0 || Boolean(draft.attachmentError ?? fileRestrictionError);
|
||||
const shouldDockRecipientSelector = !hasAttachmentPreviewContent;
|
||||
const isCompactLayout = layout === 'compact';
|
||||
const isFloatingAdaptiveWidth = widthMode === 'floating-adaptive';
|
||||
const [floatingComposerWidth, setFloatingComposerWidth] = useState(FLOATING_COMPOSER_MIN_WIDTH);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isFloatingAdaptiveWidth) return;
|
||||
|
||||
if (draft.attachments.length > 0) {
|
||||
setFloatingComposerWidth(FLOATING_COMPOSER_MAX_WIDTH);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = internalTextareaRef.current;
|
||||
if (!textarea) return;
|
||||
|
||||
const visibleText = stripEncodedTaskReferenceMetadata(draft.text);
|
||||
if (visibleText.length === 0) {
|
||||
setFloatingComposerWidth(FLOATING_COMPOSER_MIN_WIDTH);
|
||||
return;
|
||||
}
|
||||
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
context.font =
|
||||
computedStyle.font ||
|
||||
[
|
||||
computedStyle.fontStyle,
|
||||
computedStyle.fontVariant,
|
||||
computedStyle.fontWeight,
|
||||
computedStyle.fontSize,
|
||||
computedStyle.fontFamily,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const longestLineWidth = visibleText
|
||||
.split(/\r\n|\r|\n/)
|
||||
.reduce((maxWidth, line) => Math.max(maxWidth, context.measureText(line).width), 0);
|
||||
const horizontalInset =
|
||||
(Number.parseFloat(computedStyle.paddingLeft) || 0) +
|
||||
(Number.parseFloat(computedStyle.paddingRight) || 0) +
|
||||
(Number.parseFloat(computedStyle.borderLeftWidth) || 0) +
|
||||
(Number.parseFloat(computedStyle.borderRightWidth) || 0) +
|
||||
FLOATING_COMPOSER_TEXT_BUFFER;
|
||||
const nextWidth = Math.min(
|
||||
FLOATING_COMPOSER_MAX_WIDTH,
|
||||
Math.max(FLOATING_COMPOSER_MIN_WIDTH, Math.ceil(longestLineWidth + horizontalInset))
|
||||
);
|
||||
|
||||
setFloatingComposerWidth((currentWidth) =>
|
||||
currentWidth === nextWidth ? currentWidth : nextWidth
|
||||
);
|
||||
}, [draft.attachments.length, draft.text, isFloatingAdaptiveWidth]);
|
||||
|
||||
const floatingAdaptiveStyle = isFloatingAdaptiveWidth
|
||||
? {
|
||||
width: floatingComposerWidth,
|
||||
maxWidth: `min(${FLOATING_COMPOSER_MAX_WIDTH}px, calc(100vw - 2rem))`,
|
||||
}
|
||||
: undefined;
|
||||
const compactFooterNotice = slashCommandRestrictionReason ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
|
||||
<AlertCircle size={10} className="shrink-0" />
|
||||
|
|
@ -698,6 +765,7 @@ export const MessageComposer = ({
|
|||
return (
|
||||
<div
|
||||
className={cn('relative', isCompactLayout ? 'pb-1' : 'mb-1.5 pb-1.5')}
|
||||
style={floatingAdaptiveStyle}
|
||||
role="group"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ interface MessagesPanelProps {
|
|||
onRestartTeam?: () => void;
|
||||
/** Callback when a task ID link is clicked. */
|
||||
onTaskIdClick?: (taskId: string) => void;
|
||||
/** Reports the rendered floating composer height so the parent can reserve scroll space. */
|
||||
onFloatingComposerHeightChange?: (height: number) => void;
|
||||
/**
|
||||
* Scroll container owned by the parent view when `position === 'inline'`.
|
||||
* MessagesPanel does not own this element — the viewport lives in
|
||||
|
|
@ -356,6 +358,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
onReplyToMessage,
|
||||
onRestartTeam,
|
||||
onTaskIdClick,
|
||||
onFloatingComposerHeightChange,
|
||||
inlineScrollContainerRef,
|
||||
}: MessagesPanelProps): React.JSX.Element {
|
||||
const {
|
||||
|
|
@ -413,6 +416,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
effectiveMessages.length === 0 && (messagesState === undefined || messagesState.loadingHead);
|
||||
|
||||
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const floatingComposerMeasureRef = useRef<HTMLDivElement | null>(null);
|
||||
const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const bottomSheetRef = useRef<SheetRef>(null);
|
||||
const bottomSheetStickyTopRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -819,6 +823,30 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
onPositionChange('floating-composer');
|
||||
}, [onPositionChange]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (position !== 'floating-composer' || !onFloatingComposerHeightChange) return undefined;
|
||||
|
||||
const node = floatingComposerMeasureRef.current;
|
||||
if (!node) {
|
||||
onFloatingComposerHeightChange(0);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const updateHeight = (): void => {
|
||||
onFloatingComposerHeightChange(Math.ceil(node.getBoundingClientRect().height));
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
|
||||
const observer = new ResizeObserver(updateHeight);
|
||||
observer.observe(node);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
onFloatingComposerHeightChange(0);
|
||||
};
|
||||
}, [onFloatingComposerHeightChange, position]);
|
||||
|
||||
const snapBottomSheetTo = useCallback((snapIndex: number) => {
|
||||
setBottomSheetSnapIndex(snapIndex);
|
||||
bottomSheetRef.current?.snapTo(snapIndex);
|
||||
|
|
@ -877,49 +905,38 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
);
|
||||
|
||||
const floatingComposerModeControls = (
|
||||
<div className="inline-flex items-center gap-0.5 pr-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={moveToInline}
|
||||
aria-label="Move messages to inline panel"
|
||||
>
|
||||
<PanelBottom size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to inline</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={moveToBottomSheet}
|
||||
aria-label="Move messages to bottom sheet"
|
||||
>
|
||||
<PanelBottomOpen size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to bottom sheet</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={moveToSidebar}
|
||||
aria-label="Move messages to sidebar"
|
||||
>
|
||||
<PanelLeft size={13} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Move to sidebar</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="inline-flex items-center pr-1">
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)] data-[state=open]:bg-[var(--color-surface-raised)] data-[state=open]:text-[var(--color-text-secondary)]"
|
||||
aria-label="Message panel mode"
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Message panel mode</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" side="top" className="w-48">
|
||||
<DropdownMenuItem onSelect={moveToInline}>
|
||||
<PanelBottom size={14} className="shrink-0" />
|
||||
<span>Move to inline</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={moveToBottomSheet}>
|
||||
<PanelBottomOpen size={14} className="shrink-0" />
|
||||
<span>Move to bottom sheet</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={moveToSidebar}>
|
||||
<PanelLeft size={14} className="shrink-0" />
|
||||
<span>Move to sidebar</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -944,6 +961,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
<MessagesComposerSection
|
||||
teamName={teamName}
|
||||
layout="compact"
|
||||
widthMode="floating-adaptive"
|
||||
members={members}
|
||||
isTeamAlive={isTeamAlive}
|
||||
sending={sendingMessage}
|
||||
|
|
@ -1205,8 +1223,10 @@ export const MessagesPanel = memo(function MessagesPanel({
|
|||
if (position === 'floating-composer') {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-40 px-4 pb-5 sm:px-6 sm:pb-6">
|
||||
<div className="mx-auto w-full max-w-[500px]">
|
||||
<div className="pointer-events-auto">{floatingComposerSection}</div>
|
||||
<div className="mx-auto flex w-full max-w-[500px] justify-center">
|
||||
<div ref={floatingComposerMeasureRef} className="pointer-events-auto">
|
||||
{floatingComposerSection}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -377,7 +377,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
|
|||
const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
||||
const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null);
|
||||
const [pendingNewCount, setPendingNewCount] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
|
|
@ -415,6 +415,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
|
|||
setData({ lines: [], total: 0, hasMore: false });
|
||||
setPending(null);
|
||||
setPendingNewCount(0);
|
||||
setLoading(true);
|
||||
latestRef.current = null;
|
||||
atTopRef.current = true;
|
||||
setError(null);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { api } from '@renderer/api';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
|
||||
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
|
||||
import { getTaskChangeStateBucket } from '@shared/utils/taskChangeState';
|
||||
|
||||
import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout';
|
||||
import {
|
||||
|
|
@ -169,6 +170,34 @@ function resolveCacheablePresenceFromChangeSet(
|
|||
return null;
|
||||
}
|
||||
|
||||
function shouldClearSelectedTaskChangePresence(
|
||||
task: TeamTaskWithKanban,
|
||||
changeSet: TaskChangeSetV2
|
||||
): boolean {
|
||||
if (!Array.isArray(changeSet.files) || !Array.isArray(changeSet.warnings)) {
|
||||
return false;
|
||||
}
|
||||
const reviewability = classifyTaskChangeReviewability(changeSet).reviewability;
|
||||
if (reviewability === 'diagnostic_only') {
|
||||
return true;
|
||||
}
|
||||
if (reviewability !== 'unknown') {
|
||||
return false;
|
||||
}
|
||||
if (changeSet.files.length > 0 || changeSet.warnings.length > 0) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
getTaskChangeStateBucket({
|
||||
status: task.status,
|
||||
reviewState: task.reviewState,
|
||||
historyEvents: task.historyEvents,
|
||||
kanbanColumn: task.kanbanColumn,
|
||||
deletedAt: task.deletedAt,
|
||||
}) === 'active'
|
||||
);
|
||||
}
|
||||
|
||||
function isCountableTeamChangeSummary(item: TeamTaskChangeSummaryItem): boolean {
|
||||
if (item.error) {
|
||||
return true;
|
||||
|
|
@ -417,9 +446,16 @@ export function useTeamChangesSummaries({
|
|||
autoRefreshBlockedUntilRef.current = 0;
|
||||
const responseItems = getSafeResponseItems(response);
|
||||
|
||||
const currentTaskIds = new Set(tasks.map((task) => task.id));
|
||||
const taskById = new Map<string, TeamTaskWithKanban>();
|
||||
for (const task of tasks) {
|
||||
if (!taskById.has(task.id)) {
|
||||
taskById.set(task.id, task);
|
||||
}
|
||||
}
|
||||
|
||||
setChangeCountByTaskId((previous) => {
|
||||
const next: Record<string, boolean> = {};
|
||||
const currentTaskIds = new Set(tasks.map((task) => task.id));
|
||||
for (const [taskId, countable] of Object.entries(previous)) {
|
||||
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
|
||||
next[taskId] = countable;
|
||||
|
|
@ -433,14 +469,23 @@ export function useTeamChangesSummaries({
|
|||
});
|
||||
setCounterLoaded(true);
|
||||
|
||||
const currentTaskIds = new Set(tasks.map((task) => task.id));
|
||||
for (const item of responseItems) {
|
||||
const changeSet = item.changeSet;
|
||||
const options = plan.requestOptionsByTaskId.get(item.taskId);
|
||||
if (!changeSet || !options) continue;
|
||||
|
||||
const nextPresence = resolveCacheablePresenceFromChangeSet(changeSet);
|
||||
if (!nextPresence) continue;
|
||||
if (!nextPresence) {
|
||||
const task = taskById.get(item.taskId);
|
||||
if (
|
||||
task?.changePresence &&
|
||||
task.changePresence !== 'unknown' &&
|
||||
shouldClearSelectedTaskChangePresence(task, changeSet)
|
||||
) {
|
||||
setSelectedTeamTaskChangePresence(teamName, item.taskId, 'unknown');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
recordTaskChangePresence(teamName, item.taskId, options, nextPresence);
|
||||
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayou
|
|||
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
|
||||
|
|
@ -3078,6 +3079,69 @@ function extractBaseModel(raw?: string, providerId?: TeamProviderId): string | u
|
|||
return extractProviderScopedBaseModel(raw, providerId);
|
||||
}
|
||||
|
||||
function buildLaunchParamsFromRuntimeRequest(
|
||||
request: Pick<
|
||||
TeamCreateRequest,
|
||||
'providerId' | 'providerBackendId' | 'model' | 'effort' | 'fastMode' | 'limitContext'
|
||||
>,
|
||||
fallback?: TeamLaunchParams
|
||||
): TeamLaunchParams {
|
||||
const providerId = request.providerId ?? fallback?.providerId ?? 'anthropic';
|
||||
const providerChanged =
|
||||
request.providerId != null &&
|
||||
fallback?.providerId != null &&
|
||||
request.providerId !== fallback.providerId;
|
||||
const hasModel = Object.hasOwn(request, 'model');
|
||||
const baseModel =
|
||||
hasModel && typeof request.model === 'string'
|
||||
? extractBaseModel(request.model, providerId)
|
||||
: undefined;
|
||||
const rawProviderBackendId = Object.hasOwn(request, 'providerBackendId')
|
||||
? request.providerBackendId
|
||||
: providerChanged
|
||||
? undefined
|
||||
: fallback?.providerBackendId;
|
||||
return {
|
||||
providerId,
|
||||
providerBackendId: migrateProviderBackendId(providerId, rawProviderBackendId),
|
||||
model: hasModel
|
||||
? baseModel || 'default'
|
||||
: (providerChanged ? undefined : fallback?.model) || 'default',
|
||||
effort: Object.hasOwn(request, 'effort')
|
||||
? request.effort
|
||||
: providerChanged
|
||||
? undefined
|
||||
: fallback?.effort,
|
||||
fastMode: Object.hasOwn(request, 'fastMode')
|
||||
? request.fastMode
|
||||
: providerChanged
|
||||
? undefined
|
||||
: fallback?.fastMode,
|
||||
limitContext:
|
||||
typeof request.limitContext === 'boolean'
|
||||
? request.limitContext
|
||||
: providerChanged
|
||||
? false
|
||||
: (fallback?.limitContext ?? false),
|
||||
};
|
||||
}
|
||||
|
||||
function areTeamLaunchParamsEqual(
|
||||
left: TeamLaunchParams | undefined,
|
||||
right: TeamLaunchParams | undefined
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return false;
|
||||
return (
|
||||
left.providerId === right.providerId &&
|
||||
left.providerBackendId === right.providerBackendId &&
|
||||
left.model === right.model &&
|
||||
left.effort === right.effort &&
|
||||
left.fastMode === right.fastMode &&
|
||||
left.limitContext === right.limitContext
|
||||
);
|
||||
}
|
||||
|
||||
const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:';
|
||||
|
||||
function parseToolApprovalSettings(raw: string | null): ToolApprovalSettings {
|
||||
|
|
@ -5472,6 +5536,14 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
},
|
||||
},
|
||||
}));
|
||||
const optimisticLaunchParams = buildLaunchParamsFromRuntimeRequest(request);
|
||||
const previousLaunchParams = get().launchParamsByTeam[request.teamName];
|
||||
set((state) => ({
|
||||
launchParamsByTeam: {
|
||||
...state.launchParamsByTeam,
|
||||
[request.teamName]: optimisticLaunchParams,
|
||||
},
|
||||
}));
|
||||
// Initialize per-team tool approval settings based on skipPermissions flag
|
||||
const initialSettings: ToolApprovalSettings =
|
||||
request.skipPermissions === false
|
||||
|
|
@ -5487,21 +5559,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
const response = await unwrapIpc('team:create', () => api.teams.createTeam(request));
|
||||
|
||||
// Persist per-team launch params (model, effort, limit context)
|
||||
const baseModel = extractBaseModel(request.model, request.providerId);
|
||||
const params: TeamLaunchParams = {
|
||||
providerId: request.providerId ?? 'anthropic',
|
||||
providerBackendId: request.providerBackendId,
|
||||
model: baseModel || 'default',
|
||||
effort: request.effort,
|
||||
fastMode: request.fastMode,
|
||||
limitContext: request.limitContext ?? false,
|
||||
};
|
||||
saveLaunchParams(request.teamName, params);
|
||||
saveLaunchParams(request.teamName, optimisticLaunchParams);
|
||||
set((state) => ({
|
||||
launchParamsByTeam: {
|
||||
...state.launchParamsByTeam,
|
||||
[request.teamName]: params,
|
||||
[request.teamName]: optimisticLaunchParams,
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -5551,9 +5613,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (nextCurrentRunIdByTeam[request.teamName] === pendingRunId) {
|
||||
delete nextCurrentRunIdByTeam[request.teamName];
|
||||
}
|
||||
const nextLaunchParamsByTeam = { ...state.launchParamsByTeam };
|
||||
if (
|
||||
areTeamLaunchParamsEqual(nextLaunchParamsByTeam[request.teamName], optimisticLaunchParams)
|
||||
) {
|
||||
if (previousLaunchParams) {
|
||||
nextLaunchParamsByTeam[request.teamName] = previousLaunchParams;
|
||||
} else {
|
||||
delete nextLaunchParamsByTeam[request.teamName];
|
||||
}
|
||||
}
|
||||
return {
|
||||
provisioningRuns: nextRuns,
|
||||
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
|
||||
launchParamsByTeam: nextLaunchParamsByTeam,
|
||||
provisioningErrorByTeam: {
|
||||
...state.provisioningErrorByTeam,
|
||||
[request.teamName]: message,
|
||||
|
|
@ -5648,6 +5721,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
[request.teamName]: pendingRunId,
|
||||
},
|
||||
}));
|
||||
const previousLaunchParams = get().launchParamsByTeam[request.teamName];
|
||||
const optimisticLaunchParams = buildLaunchParamsFromRuntimeRequest(
|
||||
request,
|
||||
previousLaunchParams
|
||||
);
|
||||
set((state) => ({
|
||||
launchParamsByTeam: {
|
||||
...state.launchParamsByTeam,
|
||||
[request.teamName]: optimisticLaunchParams,
|
||||
},
|
||||
}));
|
||||
// Initialize per-team tool approval settings based on skipPermissions flag
|
||||
{
|
||||
const launchSettings: ToolApprovalSettings =
|
||||
|
|
@ -5660,21 +5744,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
try {
|
||||
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
|
||||
|
||||
// Persist per-team launch params (model, effort, limit context)
|
||||
const baseModel = extractBaseModel(request.model, request.providerId);
|
||||
const params: TeamLaunchParams = {
|
||||
providerId: request.providerId ?? 'anthropic',
|
||||
providerBackendId: request.providerBackendId,
|
||||
model: baseModel || 'default',
|
||||
effort: request.effort,
|
||||
fastMode: request.fastMode,
|
||||
limitContext: request.limitContext ?? false,
|
||||
};
|
||||
saveLaunchParams(request.teamName, params);
|
||||
saveLaunchParams(request.teamName, optimisticLaunchParams);
|
||||
set((state) => ({
|
||||
launchParamsByTeam: {
|
||||
...state.launchParamsByTeam,
|
||||
[request.teamName]: params,
|
||||
[request.teamName]: optimisticLaunchParams,
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
@ -5724,9 +5798,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (nextCurrentRunIdByTeam[request.teamName] === pendingRunId) {
|
||||
delete nextCurrentRunIdByTeam[request.teamName];
|
||||
}
|
||||
const nextLaunchParamsByTeam = { ...state.launchParamsByTeam };
|
||||
if (
|
||||
areTeamLaunchParamsEqual(nextLaunchParamsByTeam[request.teamName], optimisticLaunchParams)
|
||||
) {
|
||||
if (previousLaunchParams) {
|
||||
nextLaunchParamsByTeam[request.teamName] = previousLaunchParams;
|
||||
} else {
|
||||
delete nextLaunchParamsByTeam[request.teamName];
|
||||
}
|
||||
}
|
||||
return {
|
||||
provisioningRuns: nextRuns,
|
||||
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
|
||||
launchParamsByTeam: nextLaunchParamsByTeam,
|
||||
provisioningErrorByTeam: {
|
||||
...state.provisioningErrorByTeam,
|
||||
[request.teamName]: message,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import { formatBytes } from '@renderer/utils/formatters';
|
||||
import { formatTeamProviderBackendLabel } from '@renderer/utils/providerBackendIdentity';
|
||||
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider';
|
||||
|
||||
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice';
|
||||
|
|
@ -69,6 +71,16 @@ function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined):
|
|||
);
|
||||
}
|
||||
|
||||
function appendRuntimeSummarySuffixes(
|
||||
summary: string,
|
||||
backendLabel: string | undefined,
|
||||
memorySuffix: string
|
||||
): string {
|
||||
const summaryParts = new Set(summary.split(' · '));
|
||||
const backendSuffix = backendLabel && !summaryParts.has(backendLabel) ? ` · ${backendLabel}` : '';
|
||||
return `${summary}${backendSuffix}${memorySuffix}`;
|
||||
}
|
||||
|
||||
export function getRuntimeMemorySourceLabel(
|
||||
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
||||
): string | undefined {
|
||||
|
|
@ -106,48 +118,85 @@ export function resolveMemberRuntimeSummary(
|
|||
spawnEntry: MemberSpawnStatusEntry | undefined,
|
||||
runtimeEntry?: TeamAgentRuntimeEntry
|
||||
): string | undefined {
|
||||
const leadLaunchParams = isLeadMember(member) ? launchParams : undefined;
|
||||
const memberProviderBackendId = (member as ResolvedTeamMember & { providerBackendId?: string })
|
||||
.providerBackendId;
|
||||
const memberModel = member.model?.trim() || '';
|
||||
const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim();
|
||||
const inferredMemberProvider =
|
||||
inferTeamProviderIdFromModel(memberModel) ?? inferTeamProviderIdFromModel(runtimeModel);
|
||||
const runtimeModelProvider = inferTeamProviderIdFromModel(runtimeModel);
|
||||
const inferredMemberProvider = inferTeamProviderIdFromModel(memberModel) ?? runtimeModelProvider;
|
||||
const launchPending = isMemberLaunchPending(spawnEntry);
|
||||
const stalePrimaryLaneConflictsWithLaunch =
|
||||
!leadLaunchParams &&
|
||||
launchPending &&
|
||||
launchParams?.providerId != null &&
|
||||
member.laneKind === 'primary' &&
|
||||
member.laneOwnerProviderId != null &&
|
||||
member.laneOwnerProviderId !== launchParams.providerId;
|
||||
const authoritativeLaunchParams =
|
||||
leadLaunchParams ?? (stalePrimaryLaneConflictsWithLaunch ? launchParams : undefined);
|
||||
const configuredProvider: TeamProviderId =
|
||||
member.providerId ?? inferredMemberProvider ?? launchParams?.providerId ?? 'anthropic';
|
||||
const memberProviderForInheritance = member.providerId ?? inferredMemberProvider;
|
||||
authoritativeLaunchParams?.providerId ??
|
||||
member.providerId ??
|
||||
inferredMemberProvider ??
|
||||
launchParams?.providerId ??
|
||||
'anthropic';
|
||||
const memberProviderForInheritance =
|
||||
authoritativeLaunchParams?.providerId ?? member.providerId ?? inferredMemberProvider;
|
||||
const inheritsLeadRuntimeDefaults =
|
||||
memberProviderForInheritance == null ||
|
||||
launchParams?.providerId == null ||
|
||||
memberProviderForInheritance === launchParams.providerId;
|
||||
const configuredModel =
|
||||
memberModel || (inheritsLeadRuntimeDefaults ? launchParams?.model?.trim() || '' : '');
|
||||
const configuredEffort =
|
||||
member.effort ?? (inheritsLeadRuntimeDefaults ? launchParams?.effort : undefined);
|
||||
const configuredProviderBackendId =
|
||||
memberProviderBackendId ??
|
||||
(inheritsLeadRuntimeDefaults ? launchParams?.providerBackendId : undefined);
|
||||
const configuredModel = authoritativeLaunchParams
|
||||
? authoritativeLaunchParams.model?.trim() || ''
|
||||
: memberModel || (inheritsLeadRuntimeDefaults ? launchParams?.model?.trim() || '' : '');
|
||||
const configuredEffort = authoritativeLaunchParams
|
||||
? authoritativeLaunchParams.effort
|
||||
: (member.effort ?? (inheritsLeadRuntimeDefaults ? launchParams?.effort : undefined));
|
||||
const configuredProviderBackendId = authoritativeLaunchParams
|
||||
? authoritativeLaunchParams.providerBackendId
|
||||
: (memberProviderBackendId ??
|
||||
(inheritsLeadRuntimeDefaults ? launchParams?.providerBackendId : undefined));
|
||||
const runtimeProviderId = runtimeModelProvider ?? runtimeEntry?.providerId;
|
||||
const runtimeModelConflictsWithAuthoritativeLaunch =
|
||||
launchPending &&
|
||||
authoritativeLaunchParams != null &&
|
||||
runtimeModel != null &&
|
||||
(authoritativeLaunchParams.model == null ||
|
||||
extractProviderScopedBaseModel(runtimeModel, runtimeProviderId ?? configuredProvider) !==
|
||||
extractProviderScopedBaseModel(
|
||||
authoritativeLaunchParams.model,
|
||||
authoritativeLaunchParams.providerId
|
||||
));
|
||||
const runtimeConflictsWithAuthoritativeLaunch =
|
||||
authoritativeLaunchParams?.providerId != null &&
|
||||
(stalePrimaryLaneConflictsWithLaunch ||
|
||||
(runtimeProviderId != null && runtimeProviderId !== authoritativeLaunchParams.providerId) ||
|
||||
runtimeModelConflictsWithAuthoritativeLaunch);
|
||||
const displayRuntimeModel = runtimeConflictsWithAuthoritativeLaunch ? undefined : runtimeModel;
|
||||
const backendLabel = normalizeMemberBackendLabel(
|
||||
configuredProvider,
|
||||
formatTeamProviderBackendLabel(configuredProvider, configuredProviderBackendId)
|
||||
);
|
||||
const memorySuffix = shouldShowRuntimeMemory(spawnEntry, runtimeEntry)
|
||||
? ` · ${formatBytes(runtimeEntry!.rssBytes!)}`
|
||||
: '';
|
||||
const memorySuffix =
|
||||
!runtimeConflictsWithAuthoritativeLaunch && shouldShowRuntimeMemory(spawnEntry, runtimeEntry)
|
||||
? ` · ${formatBytes(runtimeEntry!.rssBytes!)}`
|
||||
: '';
|
||||
|
||||
if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) {
|
||||
const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider;
|
||||
const summary = formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort);
|
||||
return `${summary}${backendLabel ? ` · ${backendLabel}` : ''}${memorySuffix}`;
|
||||
if (displayRuntimeModel && (launchPending || configuredModel.length === 0)) {
|
||||
const runtimeProvider = runtimeModelProvider ?? configuredProvider;
|
||||
const summary = formatTeamModelSummary(runtimeProvider, displayRuntimeModel, configuredEffort);
|
||||
return appendRuntimeSummarySuffixes(summary, backendLabel, memorySuffix);
|
||||
}
|
||||
|
||||
if (isMemberLaunchPending(spawnEntry)) {
|
||||
if (!configuredModel.length && !memorySuffix) {
|
||||
if (launchPending) {
|
||||
if (!authoritativeLaunchParams && !configuredModel.length && !memorySuffix) {
|
||||
return undefined;
|
||||
}
|
||||
const summary = formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort);
|
||||
return `${summary}${backendLabel ? ` · ${backendLabel}` : ''}${memorySuffix}`;
|
||||
return appendRuntimeSummarySuffixes(summary, backendLabel, memorySuffix);
|
||||
}
|
||||
|
||||
const summary = formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort);
|
||||
return `${summary}${backendLabel ? ` · ${backendLabel}` : ''}${memorySuffix}`;
|
||||
return appendRuntimeSummarySuffixes(summary, backendLabel, memorySuffix);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1269,7 +1269,7 @@ export function getOpenCodeTeamModelRecommendation(
|
|||
if (unavailableReason) {
|
||||
return {
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
reason: unavailableReason,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -340,6 +340,22 @@ function getModelAvailabilityMap(
|
|||
);
|
||||
}
|
||||
|
||||
function getRuntimeModelAvailabilityFromLookup(
|
||||
model: string,
|
||||
visibleModelSet: ReadonlySet<string>,
|
||||
modelAvailabilityById: ReadonlyMap<string, CliProviderModelAvailability>
|
||||
): CliProviderModelAvailabilityStatus | null {
|
||||
if (!visibleModelSet.has(model)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeAvailability = modelAvailabilityById.get(model)?.status ?? null;
|
||||
if (runtimeAvailability === 'unavailable') {
|
||||
return 'unavailable';
|
||||
}
|
||||
return 'available';
|
||||
}
|
||||
|
||||
function getRuntimeModelAvailability(
|
||||
providerId: SupportedProviderId,
|
||||
model: string,
|
||||
|
|
@ -411,8 +427,10 @@ export function getAvailableTeamProviderModels(
|
|||
return [];
|
||||
}
|
||||
|
||||
return getVisibleRuntimeModels(providerId, providerStatus).filter(
|
||||
(model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available'
|
||||
const visibleModels = getVisibleRuntimeModels(providerId, providerStatus);
|
||||
const modelAvailabilityById = getModelAvailabilityMap(providerStatus);
|
||||
return visibleModels.filter(
|
||||
(model) => modelAvailabilityById.get(model)?.status !== 'unavailable'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -447,6 +465,17 @@ export function getAvailableTeamProviderModelOptions(
|
|||
getRuntimeSelectorModels(providerId, providerStatus),
|
||||
providerStatus
|
||||
);
|
||||
const runtimeVisibleModelSet = new Set(
|
||||
visibleModels.filter(
|
||||
(model) => getTeamModelUiDisabledReason(providerId, model, providerStatus) == null
|
||||
)
|
||||
);
|
||||
const modelAvailabilityById = getModelAvailabilityMap(providerStatus);
|
||||
const getPrecomputedAvailability = (model: string): CliProviderModelAvailabilityStatus | null =>
|
||||
getRuntimeModelAvailabilityFromLookup(model, runtimeVisibleModelSet, modelAvailabilityById);
|
||||
const getPrecomputedAvailabilityReason = (model: string): string | null =>
|
||||
modelAvailabilityById.get(model)?.reason ?? null;
|
||||
|
||||
return [
|
||||
{ value: '', label: 'Default', badgeLabel: 'Default' },
|
||||
...visibleModels.map((model) => {
|
||||
|
|
@ -454,8 +483,8 @@ export function getAvailableTeamProviderModelOptions(
|
|||
if (catalogOption) {
|
||||
return {
|
||||
...catalogOption,
|
||||
availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus),
|
||||
availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus),
|
||||
availabilityStatus: getPrecomputedAvailability(model),
|
||||
availabilityReason: getPrecomputedAvailabilityReason(model),
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
|
@ -465,8 +494,8 @@ export function getAvailableTeamProviderModelOptions(
|
|||
providerId === 'opencode'
|
||||
? (getTeamModelSourceBadgeLabel(providerId, model) ?? undefined)
|
||||
: undefined,
|
||||
availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus),
|
||||
availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus),
|
||||
availabilityStatus: getPrecomputedAvailability(model),
|
||||
availabilityReason: getPrecomputedAvailabilityReason(model),
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
|
@ -591,7 +620,8 @@ export function getTeamModelSelectionError(
|
|||
const availability = getRuntimeModelAvailability(providerId, trimmed, providerStatus);
|
||||
if (availability !== 'available') {
|
||||
const reason = getRuntimeModelAvailabilityReason(trimmed, providerStatus);
|
||||
return `Model "${trimmed}" is not available for the current ${getTeamProviderLabel(providerId) ?? providerId} runtime.${reason ? ` ${reason}` : ''} Pick one of the listed models or use Default.`;
|
||||
const reasonSuffix = reason ? ` ${reason}` : '';
|
||||
return `Model "${trimmed}" is not available for the current ${getTeamProviderLabel(providerId) ?? providerId} runtime.${reasonSuffix} Pick one of the listed models or use Default.`;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -84,6 +84,10 @@ export function calculateMessageCost(
|
|||
cacheReadTokens: number,
|
||||
cacheCreationTokens: number
|
||||
): number {
|
||||
if (modelName === '<synthetic>') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const pricing = getPricing(modelName);
|
||||
if (!pricing) {
|
||||
if (inputTokens > 0 || outputTokens > 0 || cacheReadTokens > 0 || cacheCreationTokens > 0) {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@ const TEAM_PROVIDER_BACKEND_IDS = new Set<TeamProviderBackendId>([
|
|||
'api',
|
||||
'cli-sdk',
|
||||
'codex-native',
|
||||
'opencode-cli',
|
||||
]);
|
||||
const GEMINI_PROVIDER_BACKEND_IDS = new Set<TeamProviderBackendId>(['auto', 'api', 'cli-sdk']);
|
||||
const OPENCODE_PROVIDER_BACKEND_IDS = new Set<TeamProviderBackendId>(['adapter', 'opencode-cli']);
|
||||
|
||||
function normalizeOptionalBackendId(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') {
|
||||
|
|
@ -46,15 +49,31 @@ export function migrateProviderBackendId(
|
|||
providerBackendId: string | null | undefined
|
||||
): TeamProviderBackendId | undefined {
|
||||
const normalizedBackendId = normalizeOptionalBackendId(providerBackendId);
|
||||
if (providerId !== 'codex') {
|
||||
return isTeamProviderBackendId(normalizedBackendId) ? normalizedBackendId : undefined;
|
||||
if (providerId === undefined || providerId === 'anthropic') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!normalizedBackendId || isLegacyCodexProviderBackendId(normalizedBackendId)) {
|
||||
return 'codex-native';
|
||||
if (providerId === 'codex') {
|
||||
if (!normalizedBackendId || isLegacyCodexProviderBackendId(normalizedBackendId)) {
|
||||
return 'codex-native';
|
||||
}
|
||||
|
||||
return normalizedBackendId === 'codex-native' ? normalizedBackendId : undefined;
|
||||
}
|
||||
|
||||
return isTeamProviderBackendId(normalizedBackendId) ? normalizedBackendId : undefined;
|
||||
if (!isTeamProviderBackendId(normalizedBackendId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (providerId === 'gemini') {
|
||||
return GEMINI_PROVIDER_BACKEND_IDS.has(normalizedBackendId) ? normalizedBackendId : undefined;
|
||||
}
|
||||
|
||||
if (providerId === 'opencode') {
|
||||
return OPENCODE_PROVIDER_BACKEND_IDS.has(normalizedBackendId) ? normalizedBackendId : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function formatProviderBackendLabel(
|
||||
|
|
|
|||
|
|
@ -216,6 +216,118 @@ describe('HTTP team runtime routes', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('validates top-level create effort against the default Anthropic provider over HTTP', async () => {
|
||||
const { app, createTeamConfig } = await createApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams',
|
||||
payload: {
|
||||
teamName: 'default-anthropic-effort-team',
|
||||
members: [{ name: 'builder' }],
|
||||
cwd: '/Users/test/project',
|
||||
effort: 'max',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(createTeamConfig).toHaveBeenCalledWith({
|
||||
teamName: 'default-anthropic-effort-team',
|
||||
members: [{ name: 'builder' }],
|
||||
cwd: '/Users/test/project',
|
||||
effort: 'max',
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('validates teammate runtime fields against the inherited top-level provider over HTTP create', async () => {
|
||||
const { app, createTeamConfig } = await createApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams',
|
||||
payload: {
|
||||
teamName: 'inherited-backend-team',
|
||||
members: [{ name: 'builder', providerBackendId: 'codex-native', effort: 'xhigh' }],
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(201);
|
||||
expect(createTeamConfig).toHaveBeenCalledWith({
|
||||
teamName: 'inherited-backend-team',
|
||||
members: [{ name: 'builder', providerBackendId: 'codex-native', effort: 'xhigh' }],
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('drops a stale known backend when launching with a different provider over HTTP', async () => {
|
||||
const { app, launchTeam } = await createApp();
|
||||
launchTeam.mockResolvedValue({ runId: 'run-2' });
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams/demo-team/launch',
|
||||
payload: {
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(launchTeam).toHaveBeenCalledWith(
|
||||
{
|
||||
teamName: 'demo-team',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
},
|
||||
expect.any(Function)
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('still rejects unknown provider backends over HTTP launch', async () => {
|
||||
const { app, launchTeam } = await createApp();
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams/demo-team/launch',
|
||||
payload: {
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: 'unknown-backend',
|
||||
model: 'sonnet',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toContain('providerBackendId must be valid');
|
||||
expect(launchTeam).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes draft team launch through createTeam with saved metadata', async () => {
|
||||
const { app, createTeam, getSavedRequest, launchTeam } = await createApp();
|
||||
getSavedRequest.mockResolvedValue({
|
||||
|
|
@ -271,6 +383,139 @@ describe('HTTP team runtime routes', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('drops stale saved draft backend when draft launch switches provider over HTTP', async () => {
|
||||
const { app, createTeam, getSavedRequest } = await createApp();
|
||||
getSavedRequest.mockResolvedValue({
|
||||
teamName: 'draft-team',
|
||||
displayName: 'Draft Team',
|
||||
cwd: '/Users/test/saved-project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
||||
});
|
||||
createTeam.mockResolvedValue({ runId: 'run-draft-anthropic' });
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams/draft-team/launch',
|
||||
payload: {
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(createTeam).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ providerBackendId: expect.any(String) }),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(createTeam).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'draft-team',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not reuse saved draft model defaults when draft launch switches provider over HTTP', async () => {
|
||||
const { app, createTeam, getSavedRequest } = await createApp();
|
||||
getSavedRequest.mockResolvedValue({
|
||||
teamName: 'draft-team',
|
||||
displayName: 'Draft Team',
|
||||
cwd: '/Users/test/saved-project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'unknown-stale-backend' as never,
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
||||
});
|
||||
createTeam.mockResolvedValue({ runId: 'run-draft-anthropic-default' });
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams/draft-team/launch',
|
||||
payload: {
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'anthropic',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [request] = createTeam.mock.calls.at(-1)!;
|
||||
expect(request).toMatchObject({
|
||||
teamName: 'draft-team',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'anthropic',
|
||||
});
|
||||
expect(request.providerBackendId).toBeUndefined();
|
||||
expect(request.model).toBeUndefined();
|
||||
expect(request.effort).toBeUndefined();
|
||||
expect(request.fastMode).toBeUndefined();
|
||||
expect(request.limitContext).toBeUndefined();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('clears saved draft model when same-provider draft launch requests default over HTTP', async () => {
|
||||
const { app, createTeam, getSavedRequest } = await createApp();
|
||||
getSavedRequest.mockResolvedValue({
|
||||
teamName: 'draft-team',
|
||||
displayName: 'Draft Team',
|
||||
cwd: '/Users/test/saved-project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
||||
});
|
||||
createTeam.mockResolvedValue({ runId: 'run-draft-codex-default' });
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams/draft-team/launch',
|
||||
payload: {
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: null,
|
||||
effort: 'low',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const [request] = createTeam.mock.calls.at(-1)!;
|
||||
expect(request).toMatchObject({
|
||||
teamName: 'draft-team',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
effort: 'low',
|
||||
});
|
||||
expect(request.model).toBeUndefined();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns saved metadata for draft team get without requiring config.json', async () => {
|
||||
const { app, getSavedRequest, getTeamData } = await createApp();
|
||||
getSavedRequest.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
SendMessageResult,
|
||||
TeamViewSnapshot,
|
||||
TeamCreateRequest,
|
||||
TeamLaunchRequest,
|
||||
TeamProviderId,
|
||||
TeamProvisioningProgress,
|
||||
} from '@shared/types/team';
|
||||
|
|
@ -3569,6 +3570,71 @@ describe('ipc teams handlers', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('createTeam validates teammate runtime fields against inherited team provider metadata', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'inherited-backend-team',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
providerBackendId: 'codex-native',
|
||||
effort: 'xhigh',
|
||||
},
|
||||
],
|
||||
cwd: os.tmpdir(),
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.createTeam.mock.calls[0][0].members).toEqual([
|
||||
{
|
||||
name: 'builder',
|
||||
role: undefined,
|
||||
workflow: undefined,
|
||||
isolation: undefined,
|
||||
providerId: undefined,
|
||||
providerBackendId: 'codex-native',
|
||||
model: undefined,
|
||||
effort: 'xhigh',
|
||||
fastMode: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('createTeam preserves top-level OpenCode provider and inherited teammate backend', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'opencode-runtime-team',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
providerBackendId: 'opencode-cli',
|
||||
},
|
||||
],
|
||||
cwd: os.tmpdir(),
|
||||
providerId: 'opencode',
|
||||
providerBackendId: 'opencode-cli',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.createTeam).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'opencode-runtime-team',
|
||||
providerId: 'opencode',
|
||||
providerBackendId: 'opencode-cli',
|
||||
members: [
|
||||
expect.objectContaining({
|
||||
name: 'builder',
|
||||
providerId: undefined,
|
||||
providerBackendId: 'opencode-cli',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('handleCreateConfig accepts members: []', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
||||
const result = (await handler({} as never, {
|
||||
|
|
@ -3644,6 +3710,124 @@ describe('ipc teams handlers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('handleCreateConfig validates teammate runtime fields against inherited team provider metadata', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
||||
const result = (await handler({} as never, {
|
||||
teamName: 'draft-inherited-runtime',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
providerBackendId: 'codex-native',
|
||||
effort: 'xhigh',
|
||||
},
|
||||
],
|
||||
cwd: os.tmpdir(),
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.createTeamConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'draft-inherited-runtime',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
members: [
|
||||
expect.objectContaining({
|
||||
name: 'builder',
|
||||
providerId: undefined,
|
||||
providerBackendId: 'codex-native',
|
||||
effort: 'xhigh',
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handleCreateConfig rejects stale inherited teammate backends for the selected team provider', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
||||
const result = (await handler({} as never, {
|
||||
teamName: 'draft-stale-runtime',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
providerBackendId: 'codex-native',
|
||||
},
|
||||
],
|
||||
cwd: os.tmpdir(),
|
||||
providerId: 'anthropic',
|
||||
})) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('providerBackendId must be valid');
|
||||
expect(service.createTeamConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handleCreateConfig drops known stale top-level backend when provider is omitted', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
||||
const result = (await handler({} as never, {
|
||||
teamName: 'draft-stale-top-level-runtime',
|
||||
members: [{ name: 'builder' }],
|
||||
cwd: os.tmpdir(),
|
||||
providerBackendId: 'codex-native',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.createTeamConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'draft-stale-top-level-runtime',
|
||||
providerId: undefined,
|
||||
providerBackendId: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handleCreateConfig validates teammate effort against default Anthropic provider metadata', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
||||
const result = (await handler({} as never, {
|
||||
teamName: 'draft-default-anthropic-runtime',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
effort: 'max',
|
||||
},
|
||||
],
|
||||
cwd: os.tmpdir(),
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.createTeamConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'draft-default-anthropic-runtime',
|
||||
members: [
|
||||
expect.objectContaining({
|
||||
name: 'builder',
|
||||
effort: 'max',
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handleCreateConfig validates top-level effort against default Anthropic provider metadata', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
||||
const result = (await handler({} as never, {
|
||||
teamName: 'draft-default-anthropic-effort',
|
||||
members: [{ name: 'builder' }],
|
||||
cwd: os.tmpdir(),
|
||||
effort: 'max',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.createTeamConfig).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'draft-default-anthropic-effort',
|
||||
providerId: undefined,
|
||||
effort: 'max',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('launches draft team through saved request without dropping Electron draft metadata', async () => {
|
||||
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-launch-'));
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
|
|
@ -3790,6 +3974,407 @@ describe('ipc teams handlers', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('prefers Anthropic launch identity over stale root Codex backend during launch', async () => {
|
||||
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-launch-provider-identity-'));
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
try {
|
||||
const teamDir = path.join(claudeRoot, 'teams', 'anthropic-team');
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({ teamName: 'anthropic-team' })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'team.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
displayName: 'Anthropic Team',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
launchIdentity: {
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: null,
|
||||
selectedModel: 'opus[1m]',
|
||||
selectedModelKind: 'explicit',
|
||||
resolvedLaunchModel: 'opus[1m]',
|
||||
catalogId: 'opus',
|
||||
catalogSource: 'runtime',
|
||||
catalogFetchedAt: null,
|
||||
selectedEffort: 'low',
|
||||
resolvedEffort: 'low',
|
||||
selectedFastMode: 'inherit',
|
||||
resolvedFastMode: null,
|
||||
fastResolutionReason: null,
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
|
||||
const handler = handlers.get(TEAM_LAUNCH)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'anthropic-team',
|
||||
cwd: os.tmpdir(),
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result).toMatchObject({ success: true });
|
||||
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'anthropic-team',
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
model: 'opus[1m]',
|
||||
effort: 'low',
|
||||
fastMode: 'inherit',
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('lets an explicit relaunch payload override stale persisted provider and model metadata', async () => {
|
||||
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-relaunch-provider-change-'));
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
try {
|
||||
const teamDir = path.join(claudeRoot, 'teams', 'runtime-change-team');
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({ teamName: 'runtime-change-team' })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'team.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
displayName: 'Runtime Change Team',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
launchIdentity: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
selectedModel: 'gpt-5.5',
|
||||
selectedModelKind: 'explicit',
|
||||
resolvedLaunchModel: 'gpt-5.5',
|
||||
catalogId: 'gpt-5.5',
|
||||
catalogSource: 'runtime',
|
||||
catalogFetchedAt: null,
|
||||
selectedEffort: 'medium',
|
||||
resolvedEffort: 'medium',
|
||||
selectedFastMode: 'inherit',
|
||||
resolvedFastMode: null,
|
||||
fastResolutionReason: null,
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
|
||||
const handler = handlers.get(TEAM_LAUNCH)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'runtime-change-team',
|
||||
cwd: os.tmpdir(),
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
fastMode: 'inherit',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result).toMatchObject({ success: true });
|
||||
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'runtime-change-team',
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
fastMode: 'inherit',
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not reuse a persisted model when an explicit relaunch changes provider without a model', async () => {
|
||||
const claudeRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ipc-relaunch-provider-change-default-model-')
|
||||
);
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
try {
|
||||
const teamDir = path.join(claudeRoot, 'teams', 'runtime-default-change-team');
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({ teamName: 'runtime-default-change-team' })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'team.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
displayName: 'Runtime Default Change Team',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
launchIdentity: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
selectedModel: 'gpt-5.5',
|
||||
selectedModelKind: 'explicit',
|
||||
resolvedLaunchModel: 'gpt-5.5',
|
||||
catalogId: 'gpt-5.5',
|
||||
catalogSource: 'runtime',
|
||||
catalogFetchedAt: null,
|
||||
selectedEffort: 'medium',
|
||||
resolvedEffort: 'medium',
|
||||
selectedFastMode: 'on',
|
||||
resolvedFastMode: true,
|
||||
fastResolutionReason: null,
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
|
||||
const handler = handlers.get(TEAM_LAUNCH)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'runtime-default-change-team',
|
||||
cwd: os.tmpdir(),
|
||||
providerId: 'anthropic',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result).toMatchObject({ success: true });
|
||||
const [request] = provisioningService.launchTeam.mock.calls.at(
|
||||
-1
|
||||
) as unknown as [TeamLaunchRequest, (progress: TeamProvisioningProgress) => void];
|
||||
expect(request).toMatchObject({
|
||||
teamName: 'runtime-default-change-team',
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
});
|
||||
expect(request.model).toBeUndefined();
|
||||
expect(request.effort).toBeUndefined();
|
||||
expect(request.fastMode).toBeUndefined();
|
||||
expect(request.limitContext).toBeUndefined();
|
||||
} finally {
|
||||
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps persisted backend when an explicit relaunch repeats the same provider without backend', async () => {
|
||||
const claudeRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ipc-relaunch-same-provider-backend-')
|
||||
);
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
try {
|
||||
const teamDir = path.join(claudeRoot, 'teams', 'gemini-backend-team');
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({ teamName: 'gemini-backend-team' })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'team.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
displayName: 'Gemini Backend Team',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'gemini',
|
||||
providerBackendId: 'api',
|
||||
model: 'gemini-3-pro',
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
|
||||
const handler = handlers.get(TEAM_LAUNCH)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'gemini-backend-team',
|
||||
cwd: os.tmpdir(),
|
||||
providerId: 'gemini',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result).toMatchObject({ success: true });
|
||||
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'gemini-backend-team',
|
||||
providerId: 'gemini',
|
||||
providerBackendId: 'api',
|
||||
model: 'gemini-3-pro',
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('clears a persisted model when an explicit relaunch repeats the provider with default model', async () => {
|
||||
const claudeRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ipc-relaunch-same-provider-default-model-')
|
||||
);
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
try {
|
||||
const teamDir = path.join(claudeRoot, 'teams', 'codex-default-model-team');
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({ teamName: 'codex-default-model-team' })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'team.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
displayName: 'Codex Default Model Team',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
launchIdentity: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
selectedModel: 'gpt-5.5',
|
||||
selectedModelKind: 'explicit',
|
||||
resolvedLaunchModel: 'gpt-5.5',
|
||||
catalogId: 'gpt-5.5',
|
||||
catalogSource: 'runtime',
|
||||
catalogFetchedAt: null,
|
||||
selectedEffort: 'medium',
|
||||
resolvedEffort: 'medium',
|
||||
selectedFastMode: 'inherit',
|
||||
resolvedFastMode: null,
|
||||
fastResolutionReason: null,
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
|
||||
const handler = handlers.get(TEAM_LAUNCH)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'codex-default-model-team',
|
||||
cwd: os.tmpdir(),
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: undefined,
|
||||
effort: 'low',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result).toMatchObject({ success: true });
|
||||
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'codex-default-model-team',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: undefined,
|
||||
effort: 'low',
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('drops a known stale providerBackendId from explicit Anthropic relaunch payloads', async () => {
|
||||
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-relaunch-stale-backend-'));
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
try {
|
||||
const teamDir = path.join(claudeRoot, 'teams', 'runtime-backend-change-team');
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({ teamName: 'runtime-backend-change-team' })
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'team.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
displayName: 'Runtime Backend Change Team',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
|
||||
const handler = handlers.get(TEAM_LAUNCH)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'runtime-backend-change-team',
|
||||
cwd: os.tmpdir(),
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
fastMode: 'inherit',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result).toMatchObject({ success: true });
|
||||
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'runtime-backend-change-team',
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
fastMode: 'inherit',
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('still rejects unknown providerBackendId values during launch', async () => {
|
||||
const handler = handlers.get(TEAM_LAUNCH)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'my-team',
|
||||
cwd: os.tmpdir(),
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: 'not-a-backend',
|
||||
model: 'sonnet',
|
||||
})) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('providerBackendId must be valid');
|
||||
expect(provisioningService.launchTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('launchTeam preserves top-level OpenCode provider and backend', async () => {
|
||||
const handler = handlers.get(TEAM_LAUNCH)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'opencode-runtime-team',
|
||||
cwd: os.tmpdir(),
|
||||
providerId: 'opencode',
|
||||
providerBackendId: 'opencode-cli',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
effort: 'medium',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.launchTeam).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teamName: 'opencode-runtime-team',
|
||||
providerId: 'opencode',
|
||||
providerBackendId: 'opencode-cli',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
effort: 'medium',
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it('handleReplaceMembers accepts members: []', async () => {
|
||||
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
|
|
|
|||
|
|
@ -170,6 +170,32 @@ describe('ChunkBuilder', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('should extract semantic output from string assistant content', () => {
|
||||
const messages = [
|
||||
createMessage({
|
||||
type: 'assistant',
|
||||
content: 'Assistant: visible activity from member logs',
|
||||
}),
|
||||
];
|
||||
|
||||
const chunks = builder.buildChunks(messages);
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(isAIChunk(chunks[0])).toBe(true);
|
||||
|
||||
if (isAIChunk(chunks[0])) {
|
||||
expect(chunks[0].semanticSteps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'output',
|
||||
content: expect.objectContaining({
|
||||
outputText: 'Assistant: visible activity from member logs',
|
||||
}),
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should group consecutive assistant messages into one AIChunk', () => {
|
||||
const messages = [
|
||||
createMessage({
|
||||
|
|
@ -273,7 +299,7 @@ describe('ChunkBuilder', () => {
|
|||
expect(chunks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should filter out synthetic assistant messages', () => {
|
||||
it('should filter out empty synthetic assistant messages', () => {
|
||||
const messages = [
|
||||
createMessage({
|
||||
type: 'assistant',
|
||||
|
|
@ -286,6 +312,33 @@ describe('ChunkBuilder', () => {
|
|||
expect(chunks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should keep synthetic assistant messages with renderable content', () => {
|
||||
const messages = [
|
||||
createMessage({
|
||||
type: 'assistant',
|
||||
content: [{ type: 'text', text: 'Codex-native assistant activity' }],
|
||||
model: '<synthetic>',
|
||||
}),
|
||||
];
|
||||
|
||||
const chunks = builder.buildChunks(messages);
|
||||
expect(chunks).toHaveLength(1);
|
||||
expect(isAIChunk(chunks[0])).toBe(true);
|
||||
|
||||
if (isAIChunk(chunks[0])) {
|
||||
expect(chunks[0].semanticSteps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: 'output',
|
||||
content: expect.objectContaining({
|
||||
outputText: 'Codex-native assistant activity',
|
||||
}),
|
||||
}),
|
||||
])
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should filter out caveat messages', () => {
|
||||
const messages = [
|
||||
createMessage({
|
||||
|
|
|
|||
550
test/main/services/team/AnthropicLaunchSelection.live.test.ts
Normal file
550
test/main/services/team/AnthropicLaunchSelection.live.test.ts
Normal file
|
|
@ -0,0 +1,550 @@
|
|||
import { constants as fsConstants, promises as fs } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||
import {
|
||||
encodePath,
|
||||
encodePathPortable,
|
||||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
setClaudeBasePathOverride,
|
||||
} from '../../../../src/main/utils/pathDecoder';
|
||||
import { killProcessByPid } from '../../../../src/main/utils/processKill';
|
||||
|
||||
import type {
|
||||
TeamAgentRuntimeSnapshot,
|
||||
TeamCreateRequest,
|
||||
TeamMember,
|
||||
TeamProvisioningProgress,
|
||||
} from '../../../../src/shared/types';
|
||||
|
||||
vi.mock('../../../../src/main/services/infrastructure/NotificationManager', () => ({
|
||||
NotificationManager: {
|
||||
getInstance: () => ({
|
||||
addTeamNotification: vi.fn(async () => undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const liveDescribe =
|
||||
process.env.ANTHROPIC_LAUNCH_SELECTION_LIVE === '1' &&
|
||||
(Boolean(process.env.ANTHROPIC_API_KEY?.trim()) || usingAnthropicSubscriptionAuth())
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
|
||||
const DEFAULT_LEAD_MODEL = 'sonnet';
|
||||
const DEFAULT_MEMBER_MODEL = 'haiku';
|
||||
const DEFAULT_LEAD_EFFORT = 'low' as const;
|
||||
|
||||
liveDescribe('Anthropic launch selection live e2e', () => {
|
||||
let tempDir: string;
|
||||
let tempClaudeRoot: string;
|
||||
let tempHome: string;
|
||||
let projectPath: string;
|
||||
let previousCliPath: string | undefined;
|
||||
let previousCliFlavor: string | undefined;
|
||||
let previousHome: string | undefined;
|
||||
let previousUserProfile: string | undefined;
|
||||
let previousNodeEnv: string | undefined;
|
||||
let previousAnthropicApiKey: string | undefined;
|
||||
let previousAnthropicAuthToken: string | undefined;
|
||||
let previousDisableAppBootstrap: string | undefined;
|
||||
let previousDisableRuntimeBootstrap: string | undefined;
|
||||
let previousClaudeJsonConfig: string | null | undefined;
|
||||
let svc: TeamProvisioningService | null;
|
||||
let teamName: string | null;
|
||||
let subscriptionAuth = false;
|
||||
|
||||
beforeEach(async () => {
|
||||
subscriptionAuth = usingAnthropicSubscriptionAuth();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'anthropic-launch-selection-live-'));
|
||||
tempClaudeRoot = subscriptionAuth ? os.userInfo().homedir : path.join(tempDir, '.claude');
|
||||
tempHome = path.join(tempDir, 'home');
|
||||
projectPath = path.join(tempDir, 'project');
|
||||
if (!subscriptionAuth) {
|
||||
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||
}
|
||||
await fs.mkdir(tempHome, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectPath, 'README.md'),
|
||||
'# Anthropic launch selection live e2e\n\nKeep this project intentionally tiny.\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
if (subscriptionAuth) {
|
||||
setClaudeBasePathOverride(null);
|
||||
previousClaudeJsonConfig = await upsertTrustedClaudeProjectConfig(
|
||||
tempClaudeRoot,
|
||||
projectPath
|
||||
);
|
||||
} else {
|
||||
await writeTrustedClaudeConfig(tempClaudeRoot, projectPath);
|
||||
setClaudeBasePathOverride(tempClaudeRoot);
|
||||
previousClaudeJsonConfig = undefined;
|
||||
}
|
||||
|
||||
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
|
||||
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||
previousHome = process.env.HOME;
|
||||
previousUserProfile = process.env.USERPROFILE;
|
||||
previousNodeEnv = process.env.NODE_ENV;
|
||||
previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
previousAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
|
||||
process.env.HOME = subscriptionAuth ? os.userInfo().homedir : tempHome;
|
||||
process.env.USERPROFILE = subscriptionAuth ? os.userInfo().homedir : tempHome;
|
||||
process.env.NODE_ENV = 'production';
|
||||
delete process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
delete process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
if (subscriptionAuth) {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
}
|
||||
|
||||
svc = null;
|
||||
teamName = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const beforeStopSnapshot = svc && teamName ? await safeRuntimeSnapshot(svc, teamName) : null;
|
||||
if (svc && teamName) {
|
||||
await svc.stopTeam(teamName).catch(() => undefined);
|
||||
}
|
||||
await terminateSmokeOwnedProcessBackends(beforeStopSnapshot);
|
||||
const afterStopSnapshot = svc && teamName ? await safeRuntimeSnapshot(svc, teamName) : null;
|
||||
await terminateSmokeOwnedProcessBackends(afterStopSnapshot);
|
||||
|
||||
if (subscriptionAuth && projectPath) {
|
||||
await removeClaudeProjectArtifacts(tempClaudeRoot, projectPath);
|
||||
}
|
||||
if (subscriptionAuth && teamName) {
|
||||
await removeTeamArtifacts(teamName);
|
||||
}
|
||||
if (subscriptionAuth && previousClaudeJsonConfig !== undefined) {
|
||||
await restoreClaudeJsonConfig(tempClaudeRoot, previousClaudeJsonConfig);
|
||||
}
|
||||
setClaudeBasePathOverride(null);
|
||||
|
||||
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
|
||||
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
|
||||
restoreEnv('HOME', previousHome);
|
||||
restoreEnv('USERPROFILE', previousUserProfile);
|
||||
restoreEnv('NODE_ENV', previousNodeEnv);
|
||||
restoreEnv('ANTHROPIC_API_KEY', previousAnthropicApiKey);
|
||||
restoreEnv('ANTHROPIC_AUTH_TOKEN', previousAnthropicAuthToken);
|
||||
restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap);
|
||||
restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap);
|
||||
|
||||
if (process.env.ANTHROPIC_LAUNCH_SELECTION_KEEP_TEMP === '1') {
|
||||
process.stderr.write(`[AnthropicLaunchSelection.live] preserved temp dir: ${tempDir}\n`);
|
||||
} else {
|
||||
await removeTempDirWithRetries(tempDir);
|
||||
}
|
||||
if (subscriptionAuth && projectPath) {
|
||||
await removeClaudeProjectArtifacts(tempClaudeRoot, projectPath);
|
||||
}
|
||||
if (subscriptionAuth && teamName) {
|
||||
await removeTeamArtifacts(teamName);
|
||||
}
|
||||
if (subscriptionAuth && (projectPath || teamName)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10_000));
|
||||
}
|
||||
if (subscriptionAuth && projectPath) {
|
||||
await removeClaudeProjectArtifacts(tempClaudeRoot, projectPath);
|
||||
}
|
||||
if (subscriptionAuth && teamName) {
|
||||
await removeTeamArtifacts(teamName);
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
it('launches Sonnet low lead with explicit Haiku teammate without inherited effort', async () => {
|
||||
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
|
||||
expect(orchestratorCli).toBeTruthy();
|
||||
await assertExecutable(orchestratorCli!);
|
||||
|
||||
const leadModel =
|
||||
process.env.ANTHROPIC_LAUNCH_SELECTION_LEAD_MODEL?.trim() || DEFAULT_LEAD_MODEL;
|
||||
const memberModel =
|
||||
process.env.ANTHROPIC_LAUNCH_SELECTION_MEMBER_MODEL?.trim() || DEFAULT_MEMBER_MODEL;
|
||||
const leadEffort = (process.env.ANTHROPIC_LAUNCH_SELECTION_LEAD_EFFORT?.trim() ||
|
||||
DEFAULT_LEAD_EFFORT) as TeamCreateRequest['effort'];
|
||||
|
||||
svc = new TeamProvisioningService();
|
||||
teamName = `anthropic-launch-selection-live-${Date.now()}`;
|
||||
const progressEvents: TeamProvisioningProgress[] = [];
|
||||
|
||||
const response = await svc.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: projectPath,
|
||||
providerId: 'anthropic',
|
||||
model: leadModel,
|
||||
effort: leadEffort,
|
||||
skipPermissions: true,
|
||||
prompt: 'Keep the team idle after bootstrap. Do not start extra work.',
|
||||
members: [
|
||||
{
|
||||
name: 'jack',
|
||||
role: 'Reviewer',
|
||||
providerId: 'anthropic',
|
||||
model: memberModel,
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Developer',
|
||||
},
|
||||
],
|
||||
},
|
||||
(progress) => {
|
||||
progressEvents.push(progress);
|
||||
}
|
||||
);
|
||||
|
||||
const run = (
|
||||
svc as unknown as { runs: Map<string, { allEffectiveMembers?: TeamMember[] }> }
|
||||
).runs.get(response.runId);
|
||||
expect(run?.allEffectiveMembers).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'jack',
|
||||
providerId: 'anthropic',
|
||||
model: memberModel,
|
||||
effort: undefined,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
providerId: 'anthropic',
|
||||
model: leadModel,
|
||||
effort: leadEffort,
|
||||
}),
|
||||
]);
|
||||
|
||||
await waitUntil(async () => {
|
||||
const last = progressEvents.at(-1);
|
||||
if (last?.state === 'failed') {
|
||||
throw new Error(formatProgressDump(progressEvents));
|
||||
}
|
||||
return last?.state === 'ready';
|
||||
}, 360_000);
|
||||
|
||||
await waitUntil(
|
||||
async () => {
|
||||
const statuses = await svc!.getMemberSpawnStatuses(teamName!);
|
||||
if (statuses.teamLaunchState === 'partial_failure') {
|
||||
throw new Error(await formatLaunchDiagnostics(svc!, teamName!, progressEvents));
|
||||
}
|
||||
return ['jack', 'alice'].every((memberName) => {
|
||||
const member = statuses.statuses[memberName];
|
||||
return (
|
||||
member?.status === 'online' &&
|
||||
member.launchState === 'confirmed_alive' &&
|
||||
member.bootstrapConfirmed === true
|
||||
);
|
||||
});
|
||||
},
|
||||
240_000,
|
||||
2_000,
|
||||
() => formatLaunchDiagnostics(svc!, teamName!, progressEvents)
|
||||
);
|
||||
|
||||
await waitUntil(
|
||||
async () => {
|
||||
const snapshot = await svc!.getTeamAgentRuntimeSnapshot(teamName!);
|
||||
return (
|
||||
snapshot.members.jack?.providerId === 'anthropic' &&
|
||||
snapshot.members.jack.alive === true &&
|
||||
snapshot.members.alice?.providerId === 'anthropic' &&
|
||||
snapshot.members.alice.alive === true
|
||||
);
|
||||
},
|
||||
180_000,
|
||||
2_000,
|
||||
() => formatLaunchDiagnostics(svc!, teamName!, progressEvents)
|
||||
);
|
||||
}, 480_000);
|
||||
});
|
||||
|
||||
function usingAnthropicSubscriptionAuth(): boolean {
|
||||
const mode = process.env.ANTHROPIC_LAUNCH_SELECTION_AUTH?.trim().toLowerCase();
|
||||
return mode === 'subscription' || mode === 'oauth';
|
||||
}
|
||||
|
||||
function restoreEnv(name: string, previous: string | undefined): void {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = previous;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertExecutable(filePath: string): Promise<void> {
|
||||
await fs.access(filePath, fsConstants.X_OK);
|
||||
}
|
||||
|
||||
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
||||
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
||||
const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20);
|
||||
const config: {
|
||||
projects: Record<string, { hasTrustDialogAccepted: true }>;
|
||||
customApiKeyResponses?: { approved: string[]; rejected: string[] };
|
||||
} = {
|
||||
projects: {
|
||||
[normalizedProjectPath]: {
|
||||
hasTrustDialogAccepted: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
if (approvedApiKeySuffix) {
|
||||
config.customApiKeyResponses = {
|
||||
approved: [approvedApiKeySuffix],
|
||||
rejected: [],
|
||||
};
|
||||
}
|
||||
await fs.writeFile(
|
||||
path.join(configDir, '.claude.json'),
|
||||
`${JSON.stringify(config, null, 2)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
async function upsertTrustedClaudeProjectConfig(
|
||||
configDir: string,
|
||||
projectPath: string
|
||||
): Promise<string | null> {
|
||||
const configPath = path.join(configDir, '.claude.json');
|
||||
const previous = await fs.readFile(configPath, 'utf8').catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
const existing = parseJsonObject(previous) ?? {};
|
||||
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
||||
const projects =
|
||||
existing.projects && typeof existing.projects === 'object' && !Array.isArray(existing.projects)
|
||||
? { ...(existing.projects as Record<string, unknown>) }
|
||||
: {};
|
||||
const currentProject =
|
||||
projects[normalizedProjectPath] &&
|
||||
typeof projects[normalizedProjectPath] === 'object' &&
|
||||
!Array.isArray(projects[normalizedProjectPath])
|
||||
? (projects[normalizedProjectPath] as Record<string, unknown>)
|
||||
: {};
|
||||
projects[normalizedProjectPath] = {
|
||||
...currentProject,
|
||||
hasTrustDialogAccepted: true,
|
||||
};
|
||||
await fs.mkdir(configDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
...existing,
|
||||
projects,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
return previous;
|
||||
}
|
||||
|
||||
async function restoreClaudeJsonConfig(configDir: string, previous: string | null): Promise<void> {
|
||||
const configPath = path.join(configDir, '.claude.json');
|
||||
if (previous === null) {
|
||||
await fs.rm(configPath, { force: true });
|
||||
return;
|
||||
}
|
||||
await fs.writeFile(configPath, previous, 'utf8');
|
||||
}
|
||||
|
||||
function parseJsonObject(raw: string | null): Record<string, unknown> | null {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
async function removeTempDirWithRetries(dirPath: string): Promise<void> {
|
||||
const attempts = process.platform === 'win32' ? 20 : 5;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if ((code !== 'EBUSY' && code !== 'EPERM' && code !== 'ENOTEMPTY') || attempt === attempts) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTeamArtifacts(teamName: string): Promise<void> {
|
||||
const targets = [path.join(getTeamsBasePath(), teamName), path.join(getTasksBasePath(), teamName)];
|
||||
for (let attempt = 1; attempt <= 10; attempt += 1) {
|
||||
await Promise.all(targets.map((target) => fs.rm(target, { recursive: true, force: true })));
|
||||
const stillExists = await Promise.all(targets.map(pathExists));
|
||||
if (!stillExists.some(Boolean)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
await Promise.all(targets.map((target) => fs.rm(target, { recursive: true, force: true })));
|
||||
}
|
||||
|
||||
async function removeClaudeProjectArtifacts(configDir: string, projectPath: string): Promise<void> {
|
||||
const projectPaths = new Set([projectPath]);
|
||||
if (projectPath.startsWith('/var/')) {
|
||||
projectPaths.add(`/private${projectPath}`);
|
||||
} else if (projectPath.startsWith('/private/var/')) {
|
||||
projectPaths.add(projectPath.slice('/private'.length));
|
||||
}
|
||||
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => null);
|
||||
if (canonicalProjectPath) {
|
||||
projectPaths.add(canonicalProjectPath);
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(projectPaths)
|
||||
.flatMap((candidatePath) => [encodePath(candidatePath), encodePathPortable(candidatePath)])
|
||||
.filter(Boolean)
|
||||
.flatMap((encodedProjectPath) =>
|
||||
[
|
||||
path.join(configDir, 'projects', encodedProjectPath),
|
||||
path.join(configDir, '.claude', 'projects', encodedProjectPath),
|
||||
].map((projectDir) =>
|
||||
fs.rm(projectDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function pathExists(targetPath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function safeRuntimeSnapshot(
|
||||
svc: TeamProvisioningService,
|
||||
teamName: string
|
||||
): Promise<TeamAgentRuntimeSnapshot | null> {
|
||||
return svc.getTeamAgentRuntimeSnapshot(teamName).catch(() => null);
|
||||
}
|
||||
|
||||
async function terminateSmokeOwnedProcessBackends(
|
||||
snapshot: TeamAgentRuntimeSnapshot | null
|
||||
): Promise<void> {
|
||||
const pids = new Set<number>();
|
||||
for (const member of Object.values(snapshot?.members ?? {})) {
|
||||
if (member.backendType !== 'process' || member.providerId !== 'anthropic') {
|
||||
continue;
|
||||
}
|
||||
const pid = member.runtimePid ?? member.pid;
|
||||
if (typeof pid === 'number' && Number.isFinite(pid) && pid > 0) {
|
||||
pids.add(pid);
|
||||
}
|
||||
}
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
killProcessByPid(pid);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
pollMs = 1_000,
|
||||
describeState?: () => string | Promise<string>
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
lastError = undefined;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
}
|
||||
const suffix =
|
||||
lastError instanceof Error && lastError.message ? ` Last error: ${lastError.message}` : '';
|
||||
const state = describeState ? ` Last state: ${await describeState()}` : '';
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}${state}`);
|
||||
}
|
||||
|
||||
async function formatLaunchDiagnostics(
|
||||
svc: TeamProvisioningService,
|
||||
teamName: string,
|
||||
progressEvents: TeamProvisioningProgress[]
|
||||
): Promise<string> {
|
||||
const [spawnStatuses, runtimeSnapshot] = await Promise.all([
|
||||
svc.getMemberSpawnStatuses(teamName).catch((error) => ({ error: String(error) })),
|
||||
svc.getTeamAgentRuntimeSnapshot(teamName).catch((error) => ({ error: String(error) })),
|
||||
]);
|
||||
return redactSecrets(
|
||||
JSON.stringify(
|
||||
{
|
||||
progress: formatProgressDump(progressEvents),
|
||||
spawnStatuses,
|
||||
runtimeSnapshot,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function formatProgressDump(progressEvents: TeamProvisioningProgress[]): string {
|
||||
return redactSecrets(
|
||||
progressEvents
|
||||
.map((progress) =>
|
||||
[
|
||||
progress.state,
|
||||
progress.message,
|
||||
progress.messageSeverity,
|
||||
progress.error,
|
||||
progress.cliLogsTail,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ')
|
||||
)
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
function redactSecrets(text: string): string {
|
||||
return text
|
||||
.replace(/sk-ant-api03-[A-Za-z0-9_-]+/g, '<redacted-anthropic-key>')
|
||||
.replace(/\b(?:sk|ak)-[A-Za-z0-9_-]{20,}\b/g, '<redacted-api-key>');
|
||||
}
|
||||
|
|
@ -20,7 +20,12 @@ const SUMMARY_OPTIONS = {
|
|||
summaryOnly: true,
|
||||
};
|
||||
|
||||
function buildAssistantWriteEntry(toolUseId: string, filePath: string, content: string, timestamp: string) {
|
||||
function buildAssistantWriteEntry(
|
||||
toolUseId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
timestamp: string
|
||||
) {
|
||||
return {
|
||||
timestamp,
|
||||
type: 'assistant',
|
||||
|
|
@ -39,7 +44,11 @@ function buildAssistantWriteEntry(toolUseId: string, filePath: string, content:
|
|||
}
|
||||
|
||||
async function writeJsonl(filePath: string, entries: object[]): Promise<void> {
|
||||
await fs.writeFile(filePath, entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n', 'utf8');
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
entries.map((entry) => JSON.stringify(entry)).join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
async function writeTaskFile(
|
||||
|
|
@ -57,7 +66,9 @@ async function writeTaskFile(
|
|||
status: 'completed',
|
||||
createdAt: '2026-03-01T09:55:00.000Z',
|
||||
updatedAt: '2026-03-01T10:10:00.000Z',
|
||||
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }],
|
||||
workIntervals: [
|
||||
{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' },
|
||||
],
|
||||
historyEvents: [],
|
||||
...overrides,
|
||||
},
|
||||
|
|
@ -268,7 +279,12 @@ async function writeOpenCodeLedgerEventJournal(
|
|||
}
|
||||
|
||||
function persistedEntryPath(baseDir: string): string {
|
||||
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
|
||||
return path.join(
|
||||
baseDir,
|
||||
'task-change-summaries',
|
||||
encodeURIComponent(TEAM_NAME),
|
||||
`${TASK_ID}.json`
|
||||
);
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
|
|
@ -347,12 +363,11 @@ function makeTaskChangeResult(
|
|||
endTimestamp: overrides.scope?.endTimestamp ?? '',
|
||||
toolUseIds: overrides.scope?.toolUseIds ?? [],
|
||||
filePaths: overrides.scope?.filePaths ?? files.map((file) => file.filePath),
|
||||
confidence:
|
||||
overrides.scope?.confidence ?? {
|
||||
tier: confidenceTierByLabel[confidence],
|
||||
label: confidence,
|
||||
reason: 'test fixture',
|
||||
},
|
||||
confidence: overrides.scope?.confidence ?? {
|
||||
tier: confidenceTierByLabel[confidence],
|
||||
label: confidence,
|
||||
reason: 'test fixture',
|
||||
},
|
||||
},
|
||||
warnings: overrides.warning ? [overrides.warning] : [],
|
||||
};
|
||||
|
|
@ -367,14 +382,20 @@ function pendingTaskChangeResult(): Promise<ReturnType<typeof makeTaskChangeResu
|
|||
function createService(params: {
|
||||
logPaths: string[];
|
||||
projectPath?: string;
|
||||
findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>;
|
||||
findLogFileRefsForTask?: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
options?: unknown
|
||||
) => Promise<unknown[]>;
|
||||
taskChangePresenceRepository?: {
|
||||
upsertEntry: ReturnType<typeof vi.fn>;
|
||||
deleteEntry?: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
teamLogSourceTracker?: {
|
||||
ensureTracking: ReturnType<
|
||||
typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>>
|
||||
typeof vi.fn<
|
||||
() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>
|
||||
>
|
||||
>;
|
||||
};
|
||||
taskChangeWorkerClient?: {
|
||||
|
|
@ -588,15 +609,24 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const aliceLogPath = path.join(tmpDir, 'alice.jsonl');
|
||||
await writeJsonl(aliceLogPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const findLogFileRefsForTask = vi.fn(async (_teamName: string, _taskId: string, options?: any) =>
|
||||
options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : []
|
||||
const findLogFileRefsForTask = vi.fn(
|
||||
async (_teamName: string, _taskId: string, options?: any) =>
|
||||
options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : []
|
||||
);
|
||||
const service = createService({ logPaths: [aliceLogPath], findLogFileRefsForTask }).service;
|
||||
|
||||
const empty = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'bob', status: 'completed' });
|
||||
const empty = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
});
|
||||
const populated = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
|
|
@ -613,7 +643,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-summary.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const { service, findLogFileRefsForTask } = createService({ logPaths: [logPath] });
|
||||
|
|
@ -641,7 +676,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-restart.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const first = createService({ logPaths: [logPath] });
|
||||
|
|
@ -657,6 +697,41 @@ describe('ChangeExtractorService', () => {
|
|||
expect((second.findLogFileRefsForTask as any).mock.calls.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('persists terminal summary signatures with task metadata status when request status is stale', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir);
|
||||
|
||||
const logPath = path.join(tmpDir, 'alice-stale-status.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const first = createService({ logPaths: [logPath] });
|
||||
const initial = await first.service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
...SUMMARY_OPTIONS,
|
||||
status: 'in_progress',
|
||||
stateBucket: 'active',
|
||||
});
|
||||
const persisted = JSON.parse(await fs.readFile(persistedEntryPath(tmpDir), 'utf8')) as {
|
||||
taskSignature: string;
|
||||
};
|
||||
const taskSignature = JSON.parse(persisted.taskSignature) as { status: string };
|
||||
|
||||
const second = createService({ logPaths: [logPath] });
|
||||
const restored = await second.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
||||
expect(taskSignature.status).toBe('completed');
|
||||
expect(initial.files).toHaveLength(1);
|
||||
expect(restored.files).toHaveLength(1);
|
||||
expect((second.findLogFileRefsForTask as any).mock.calls.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('forceFresh overwrites the persisted terminal summary snapshot', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
@ -664,15 +739,30 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-refresh.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const { service } = createService({ logPaths: [logPath] });
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 2;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry('tool-2', '/repo/src/extra.ts', 'export const extra = true;\n', '2026-03-01T10:02:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 2;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-2',
|
||||
'/repo/src/extra.ts',
|
||||
'export const extra = true;\n',
|
||||
'2026-03-01T10:02:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const refreshed = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
|
|
@ -696,7 +786,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-review.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const { service } = createService({ logPaths: [logPath] });
|
||||
|
|
@ -729,7 +824,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-project-drift.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
await createService({ logPaths: [logPath], projectPath: '/repo-a' }).service.getTaskChanges(
|
||||
|
|
@ -738,11 +838,7 @@ describe('ChangeExtractorService', () => {
|
|||
SUMMARY_OPTIONS
|
||||
);
|
||||
const drifted = createService({ logPaths: [logPath], projectPath: '/repo-b' });
|
||||
await drifted.service.getTaskChanges(
|
||||
TEAM_NAME,
|
||||
TASK_ID,
|
||||
SUMMARY_OPTIONS
|
||||
);
|
||||
await drifted.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
||||
expect((drifted.findLogFileRefsForTask as any).mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
|
@ -754,12 +850,25 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-missing-task.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
await createService({ logPaths: [logPath] }).service.getTaskChanges(
|
||||
TEAM_NAME,
|
||||
TASK_ID,
|
||||
SUMMARY_OPTIONS
|
||||
);
|
||||
await fs.unlink(taskPath);
|
||||
await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
await createService({ logPaths: [logPath] }).service.getTaskChanges(
|
||||
TEAM_NAME,
|
||||
TASK_ID,
|
||||
SUMMARY_OPTIONS
|
||||
);
|
||||
|
||||
await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' });
|
||||
});
|
||||
|
|
@ -771,10 +880,19 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-corrupt.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
await createService({ logPaths: [logPath] }).service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
await createService({ logPaths: [logPath] }).service.getTaskChanges(
|
||||
TEAM_NAME,
|
||||
TASK_ID,
|
||||
SUMMARY_OPTIONS
|
||||
);
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
await fs.writeFile(persistedEntryPath(tmpDir), '{bad-json', 'utf8');
|
||||
|
||||
|
|
@ -794,7 +912,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-fallback.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const service = new ChangeExtractorService(
|
||||
|
|
@ -833,10 +956,20 @@ describe('ChangeExtractorService', () => {
|
|||
const firstLogPath = path.join(tmpDir, 'first.jsonl');
|
||||
const secondLogPath = path.join(tmpDir, 'second.jsonl');
|
||||
await writeJsonl(firstLogPath, [
|
||||
buildAssistantWriteEntry('tool-1', 'C:\\repo\\src\\same.ts', 'first\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'C:\\repo\\src\\same.ts',
|
||||
'first\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
await writeJsonl(secondLogPath, [
|
||||
buildAssistantWriteEntry('tool-2', 'C:/repo/src/same.ts', 'second\n', '2026-03-01T10:01:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-2',
|
||||
'C:/repo/src/same.ts',
|
||||
'second\n',
|
||||
'2026-03-01T10:01:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const service = createService({
|
||||
|
|
@ -886,7 +1019,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const computeTaskChanges = vi.fn();
|
||||
|
|
@ -916,7 +1054,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const computeTaskChanges = vi.fn(async () => {
|
||||
|
|
@ -947,7 +1090,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const computeTaskChanges = vi.fn(async () => makeTaskChangeResult());
|
||||
|
|
@ -972,7 +1120,12 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
buildAssistantWriteEntry(
|
||||
'tool-1',
|
||||
'/repo/src/file.ts',
|
||||
'export const value = 1;\n',
|
||||
'2026-03-01T10:00:00.000Z'
|
||||
),
|
||||
]);
|
||||
|
||||
const firstWorker = {
|
||||
|
|
@ -1249,7 +1402,9 @@ describe('ChangeExtractorService', () => {
|
|||
}));
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () => makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
||||
),
|
||||
};
|
||||
const { service } = createService({
|
||||
logPaths: [],
|
||||
|
|
@ -1265,6 +1420,199 @@ describe('ChangeExtractorService', () => {
|
|||
expect(upsertEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears stale presence entries for active uncertain empty task diff results', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, {
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
|
||||
});
|
||||
|
||||
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const deleteEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const ensureTracking = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
})
|
||||
);
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(() =>
|
||||
Promise.resolve(makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' }))
|
||||
),
|
||||
};
|
||||
const { service } = createService({
|
||||
logPaths: [],
|
||||
taskChangePresenceRepository: { upsertEntry, deleteEntry },
|
||||
teamLogSourceTracker: { ensureTracking },
|
||||
taskChangeWorkerClient: workerClient,
|
||||
});
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
...SUMMARY_OPTIONS,
|
||||
status: 'in_progress',
|
||||
stateBucket: 'active',
|
||||
});
|
||||
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(upsertEntry).not.toHaveBeenCalled();
|
||||
expect(deleteEntry).toHaveBeenCalledWith(TEAM_NAME, TASK_ID);
|
||||
});
|
||||
|
||||
it('clears stale presence entries for newly created pending tasks without logs', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, {
|
||||
status: 'pending',
|
||||
workIntervals: [],
|
||||
});
|
||||
|
||||
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const deleteEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const ensureTracking = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
})
|
||||
);
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(() =>
|
||||
Promise.resolve(makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' }))
|
||||
),
|
||||
};
|
||||
const { service } = createService({
|
||||
logPaths: [],
|
||||
taskChangePresenceRepository: { upsertEntry, deleteEntry },
|
||||
teamLogSourceTracker: { ensureTracking },
|
||||
taskChangeWorkerClient: workerClient,
|
||||
});
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
...SUMMARY_OPTIONS,
|
||||
status: 'completed',
|
||||
stateBucket: 'completed',
|
||||
});
|
||||
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(upsertEntry).not.toHaveBeenCalled();
|
||||
expect(deleteEntry).toHaveBeenCalledWith(TEAM_NAME, TASK_ID);
|
||||
});
|
||||
|
||||
it('passes task metadata status to task diff workers when request status is stale', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { status: 'completed', reviewState: 'none' });
|
||||
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(() =>
|
||||
Promise.resolve(makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' }))
|
||||
),
|
||||
};
|
||||
const { service } = createService({
|
||||
logPaths: [],
|
||||
taskChangeWorkerClient: workerClient,
|
||||
});
|
||||
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
...SUMMARY_OPTIONS,
|
||||
status: 'in_progress',
|
||||
stateBucket: 'completed',
|
||||
});
|
||||
|
||||
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
|
||||
const workerCalls = workerClient.computeTaskChanges.mock.calls as unknown as Array<[unknown]>;
|
||||
expect(workerCalls[0]?.[0]).toMatchObject({
|
||||
effectiveOptions: { status: 'completed' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps stale presence entries for completed uncertain empty task diff results even when request status is stale', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { status: 'completed', reviewState: 'none' });
|
||||
|
||||
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const deleteEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const ensureTracking = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
})
|
||||
);
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(() =>
|
||||
Promise.resolve(makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' }))
|
||||
),
|
||||
};
|
||||
const { service } = createService({
|
||||
logPaths: [],
|
||||
taskChangePresenceRepository: { upsertEntry, deleteEntry },
|
||||
teamLogSourceTracker: { ensureTracking },
|
||||
taskChangeWorkerClient: workerClient,
|
||||
});
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
...SUMMARY_OPTIONS,
|
||||
status: 'in_progress',
|
||||
stateBucket: 'completed',
|
||||
});
|
||||
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(result.warnings).toEqual([]);
|
||||
expect(upsertEntry).not.toHaveBeenCalled();
|
||||
expect(deleteEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back inline before recording presence for malformed worker task diff results', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, {
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z' }],
|
||||
});
|
||||
|
||||
const upsertEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const deleteEntry = vi.fn(() => Promise.resolve(undefined));
|
||||
const ensureTracking = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
projectFingerprint: 'project-fingerprint',
|
||||
logSourceGeneration: 'log-generation',
|
||||
})
|
||||
);
|
||||
const malformedResult = {
|
||||
...makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' }),
|
||||
files: undefined,
|
||||
warnings: undefined,
|
||||
} as unknown as ReturnType<typeof makeTaskChangeResult>;
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(() => Promise.resolve(malformedResult)),
|
||||
};
|
||||
const { service } = createService({
|
||||
logPaths: [],
|
||||
taskChangePresenceRepository: { upsertEntry, deleteEntry },
|
||||
teamLogSourceTracker: { ensureTracking },
|
||||
taskChangeWorkerClient: workerClient,
|
||||
});
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
...SUMMARY_OPTIONS,
|
||||
status: 'in_progress',
|
||||
stateBucket: 'active',
|
||||
});
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
expect(upsertEntry).not.toHaveBeenCalled();
|
||||
expect(deleteEntry).toHaveBeenCalledWith(TEAM_NAME, TASK_ID);
|
||||
});
|
||||
|
||||
it('runs OpenCode recovery when a ledger result only contains warning notices', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
|
|||
|
|
@ -381,6 +381,33 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
expect(launchArg?.members[0]?.prompt).not.toContain('Join team "team-a"');
|
||||
});
|
||||
|
||||
it('refreshes readiness and retries once when the launch handshake sees a newer capability snapshot', async () => {
|
||||
const { result, checkReadiness, launchOpenCodeTeam } =
|
||||
await launchWithStaleCapabilitySnapshotRecovery('Bridge server capability snapshot mismatch');
|
||||
|
||||
expect(result.teamLaunchState).toBe('clean_success');
|
||||
expect(result.warnings).toContain(
|
||||
'OpenCode capability snapshot changed between readiness and launch; refreshed readiness and retried once.'
|
||||
);
|
||||
expect(checkReadiness).toHaveBeenCalledTimes(2);
|
||||
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2);
|
||||
expect(launchOpenCodeTeam.mock.calls[0]?.[0].expectedCapabilitySnapshotId).toBe('cap-old');
|
||||
expect(launchOpenCodeTeam.mock.calls[1]?.[0].expectedCapabilitySnapshotId).toBe('cap-new');
|
||||
});
|
||||
|
||||
it('refreshes readiness and retries once when the launch command sees a newer capability snapshot', async () => {
|
||||
const { result, checkReadiness, launchOpenCodeTeam } =
|
||||
await launchWithStaleCapabilitySnapshotRecovery(
|
||||
'OpenCode bridge capability snapshot precondition mismatch'
|
||||
);
|
||||
|
||||
expect(result.teamLaunchState).toBe('clean_success');
|
||||
expect(checkReadiness).toHaveBeenCalledTimes(2);
|
||||
expect(launchOpenCodeTeam).toHaveBeenCalledTimes(2);
|
||||
expect(launchOpenCodeTeam.mock.calls[0]?.[0].expectedCapabilitySnapshotId).toBe('cap-old');
|
||||
expect(launchOpenCodeTeam.mock.calls[1]?.[0].expectedCapabilitySnapshotId).toBe('cap-new');
|
||||
});
|
||||
|
||||
it('does not mark the lane clean_success when ready bridge data omits an expected member', async () => {
|
||||
const launchOpenCodeTeam = vi.fn(
|
||||
async () =>
|
||||
|
|
@ -1097,6 +1124,87 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
async function launchWithStaleCapabilitySnapshotRecovery(message: string) {
|
||||
let readinessCalls = 0;
|
||||
let capabilitySnapshotId = 'cap-old';
|
||||
const checkReadiness = vi.fn<OpenCodeTeamRuntimeBridgePort['checkOpenCodeTeamLaunchReadiness']>(
|
||||
() => {
|
||||
readinessCalls += 1;
|
||||
capabilitySnapshotId = readinessCalls === 1 ? 'cap-old' : 'cap-new';
|
||||
return Promise.resolve(readiness({ state: 'ready', launchAllowed: true }));
|
||||
}
|
||||
);
|
||||
const launchOpenCodeTeam = vi.fn<
|
||||
NonNullable<OpenCodeTeamRuntimeBridgePort['launchOpenCodeTeam']>
|
||||
>((input) =>
|
||||
Promise.resolve(
|
||||
input.expectedCapabilitySnapshotId === 'cap-old'
|
||||
? failedCapabilitySnapshotLaunchData(message)
|
||||
: successfulOpenCodeLaunchData()
|
||||
)
|
||||
);
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter({
|
||||
checkOpenCodeTeamLaunchReadiness: checkReadiness,
|
||||
getLastOpenCodeRuntimeSnapshot: vi.fn(() => runtimeSnapshot(capabilitySnapshotId)),
|
||||
launchOpenCodeTeam,
|
||||
});
|
||||
|
||||
return {
|
||||
result: await adapter.launch(launchInput()),
|
||||
checkReadiness,
|
||||
launchOpenCodeTeam,
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeSnapshot(capabilitySnapshotId: string) {
|
||||
return {
|
||||
providerId: 'opencode' as const,
|
||||
binaryPath: '/opt/homebrew/bin/opencode',
|
||||
binaryFingerprint: 'version:1.14.19',
|
||||
version: '1.14.19',
|
||||
capabilitySnapshotId,
|
||||
};
|
||||
}
|
||||
|
||||
function successfulOpenCodeLaunchData(): OpenCodeLaunchTeamCommandData {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'ready',
|
||||
members: {
|
||||
alice: {
|
||||
sessionId: 'oc-session-1',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimePid: 123,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
evidence: [
|
||||
{ kind: 'required_tools_proven', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'delivery_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'member_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
{ kind: 'run_ready', observedAt: '2026-04-21T00:00:00.000Z' },
|
||||
],
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function failedCapabilitySnapshotLaunchData(message: string): OpenCodeLaunchTeamCommandData {
|
||||
return {
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'failed',
|
||||
members: {},
|
||||
warnings: [],
|
||||
diagnostics: [
|
||||
{
|
||||
code: 'opencode_bridge',
|
||||
severity: 'error',
|
||||
message: `OpenCode bridge failed: ${message}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function bridgePort(
|
||||
readinessResult: OpenCodeTeamLaunchReadiness,
|
||||
overrides: Partial<OpenCodeTeamRuntimeBridgePort> = {}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,22 @@ function metadataOnlyMultiFileEditToolUse(
|
|||
};
|
||||
}
|
||||
|
||||
function createNoLogTaskChangeComputer(): TaskChangeComputer {
|
||||
const logsFinder = {
|
||||
findLogFileRefsForTask: () => Promise.resolve([]),
|
||||
};
|
||||
const boundaryParser = {
|
||||
parseBoundaries: () =>
|
||||
Promise.resolve({
|
||||
boundaries: [],
|
||||
scopes: [],
|
||||
isSingleTaskSession: true,
|
||||
detectedMechanism: 'none' as const,
|
||||
}),
|
||||
};
|
||||
return new TaskChangeComputer(logsFinder as never, boundaryParser as never);
|
||||
}
|
||||
|
||||
describe('TaskChangeComputer', () => {
|
||||
let tmpDir: string | null = null;
|
||||
|
||||
|
|
@ -93,6 +109,86 @@ describe('TaskChangeComputer', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('keeps active tasks without logs quiet even when request status is stale', async () => {
|
||||
const computer = createNoLogTaskChangeComputer();
|
||||
|
||||
const result = await computer.computeTaskChanges({
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-1',
|
||||
taskMeta: {
|
||||
status: 'in_progress',
|
||||
reviewState: 'none',
|
||||
},
|
||||
effectiveOptions: { status: 'completed' },
|
||||
projectPath: '/repo',
|
||||
includeDetails: false,
|
||||
});
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.confidence).toBe('fallback');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps newly created pending tasks without logs quiet', async () => {
|
||||
const computer = createNoLogTaskChangeComputer();
|
||||
|
||||
const result = await computer.computeTaskChanges({
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-1',
|
||||
taskMeta: {
|
||||
status: 'pending',
|
||||
reviewState: 'none',
|
||||
},
|
||||
effectiveOptions: { status: 'completed' },
|
||||
projectPath: '/repo',
|
||||
includeDetails: false,
|
||||
});
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.confidence).toBe('fallback');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('warns when completed tasks have no logs even when request status is stale', async () => {
|
||||
const computer = createNoLogTaskChangeComputer();
|
||||
|
||||
const result = await computer.computeTaskChanges({
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-1',
|
||||
taskMeta: {
|
||||
status: 'completed',
|
||||
reviewState: 'none',
|
||||
},
|
||||
effectiveOptions: { status: 'in_progress' },
|
||||
projectPath: '/repo',
|
||||
includeDetails: false,
|
||||
});
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.confidence).toBe('fallback');
|
||||
expect(result.warnings).toEqual(['No log files found for this task.']);
|
||||
});
|
||||
|
||||
it('keeps reopened needs-fix tasks quiet even when their base status is completed', async () => {
|
||||
const computer = createNoLogTaskChangeComputer();
|
||||
|
||||
const result = await computer.computeTaskChanges({
|
||||
teamName: 'team-a',
|
||||
taskId: 'task-1',
|
||||
taskMeta: {
|
||||
status: 'completed',
|
||||
reviewState: 'needsFix',
|
||||
},
|
||||
effectiveOptions: { status: 'completed' },
|
||||
projectPath: '/repo',
|
||||
includeDetails: false,
|
||||
});
|
||||
|
||||
expect(result.files).toEqual([]);
|
||||
expect(result.confidence).toBe('fallback');
|
||||
expect(result.warnings).toEqual([]);
|
||||
});
|
||||
|
||||
it('shares concurrent JSONL parsing and invalidates when the file changes', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
|
||||
const logPath = path.join(tmpDir, 'agent.jsonl');
|
||||
|
|
@ -452,7 +548,11 @@ describe('TaskChangeComputer', () => {
|
|||
const logPath = path.join(tmpDir, 'agent.jsonl');
|
||||
await writeJsonl(logPath, [
|
||||
metadataOnlyMultiFileEditToolUse('tool-1', ['/repo/index.html', '/repo/style.css']),
|
||||
metadataOnlyMultiFileEditToolUse('tool-1', ['/repo/177/landing.css'], '/repo/177/landing.css'),
|
||||
metadataOnlyMultiFileEditToolUse(
|
||||
'tool-1',
|
||||
['/repo/177/landing.css'],
|
||||
'/repo/177/landing.css'
|
||||
),
|
||||
]);
|
||||
|
||||
const logsFinder = {
|
||||
|
|
|
|||
|
|
@ -13680,6 +13680,638 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps runtime snapshot on current Anthropic provider while stale Codex relaunch metadata remains', async () => {
|
||||
const teamName = 'provider-switch-codex-anthropic-runtime-card-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath });
|
||||
await writeTeamMeta(teamName, projectPath);
|
||||
await writeMembersMeta(teamName);
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const firstRun = createMixedLiveRun({ teamName, projectPath });
|
||||
firstRun.runId = `run-${teamName}-codex`;
|
||||
firstRun.child = { pid: 64611, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, firstRun);
|
||||
|
||||
const beforeSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(beforeSwitch).toMatchObject({
|
||||
runId: firstRun.runId,
|
||||
providerBackendId: 'codex-native',
|
||||
members: {
|
||||
'team-lead': { runtimeModel: 'gpt-5.4' },
|
||||
alice: { providerId: 'codex', runtimeModel: 'gpt-5.4-mini' },
|
||||
bob: { providerId: 'opencode', runtimeModel: 'opencode/minimax-m2.5-free' },
|
||||
},
|
||||
});
|
||||
|
||||
await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
const secondRun = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
secondRun.runId = `run-${teamName}-anthropic`;
|
||||
secondRun.request.model = 'haiku';
|
||||
secondRun.request.effort = 'low';
|
||||
secondRun.launchIdentity = {
|
||||
...secondRun.launchIdentity,
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: null,
|
||||
selectedModel: 'haiku',
|
||||
resolvedLaunchModel: 'haiku',
|
||||
catalogId: 'haiku',
|
||||
selectedEffort: 'low',
|
||||
resolvedEffort: 'low',
|
||||
};
|
||||
secondRun.child = { pid: 64621, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, secondRun);
|
||||
|
||||
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(afterSwitch).toMatchObject({
|
||||
runId: secondRun.runId,
|
||||
members: {
|
||||
'team-lead': { runtimeModel: 'haiku' },
|
||||
alice: { providerId: 'anthropic', runtimeModel: 'haiku' },
|
||||
bob: { providerId: 'opencode', runtimeModel: 'opencode/minimax-m2.5-free' },
|
||||
tom: { providerId: 'opencode', runtimeModel: 'opencode/nemotron-3-super-free' },
|
||||
},
|
||||
});
|
||||
expect(afterSwitch.providerBackendId).toBeUndefined();
|
||||
expect(afterSwitch.members.alice.providerBackendId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores stale Codex live metadata model for the current Anthropic provider snapshot', async () => {
|
||||
const teamName = 'provider-switch-anthropic-stale-live-codex-model-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath });
|
||||
await writeTeamMeta(teamName, projectPath);
|
||||
await writeMembersMeta(teamName);
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const firstRun = createMixedLiveRun({ teamName, projectPath });
|
||||
firstRun.runId = `run-${teamName}-codex`;
|
||||
firstRun.child = { pid: 64651, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, firstRun);
|
||||
|
||||
const beforeSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(beforeSwitch.members.alice).toMatchObject({
|
||||
providerId: 'codex',
|
||||
runtimeModel: 'gpt-5.4-mini',
|
||||
});
|
||||
|
||||
await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
const secondRun = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
secondRun.runId = `run-${teamName}-anthropic`;
|
||||
secondRun.child = { pid: 64661, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, secondRun);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
alive: true,
|
||||
pid: 64662,
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4-mini',
|
||||
},
|
||||
],
|
||||
]);
|
||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
||||
|
||||
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(afterSwitch).toMatchObject({
|
||||
runId: secondRun.runId,
|
||||
members: {
|
||||
'team-lead': { runtimeModel: 'sonnet' },
|
||||
alice: {
|
||||
providerId: 'anthropic',
|
||||
runtimeModel: 'haiku',
|
||||
pid: 64662,
|
||||
rssBytes: 64_662_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(afterSwitch.providerBackendId).toBeUndefined();
|
||||
expect(afterSwitch.members.alice.providerBackendId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores stale Codex live provider evidence even when the live model cannot be inferred', async () => {
|
||||
const teamName = 'provider-switch-anthropic-stale-live-codex-unknown-model-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath });
|
||||
await writeTeamMeta(teamName, projectPath);
|
||||
await writeMembersMeta(teamName);
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const firstRun = createMixedLiveRun({ teamName, projectPath });
|
||||
firstRun.runId = `run-${teamName}-codex`;
|
||||
firstRun.child = { pid: 64711, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, firstRun);
|
||||
|
||||
await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
const secondRun = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
secondRun.runId = `run-${teamName}-anthropic`;
|
||||
secondRun.child = { pid: 64721, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, secondRun);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
alive: true,
|
||||
pid: 64722,
|
||||
providerId: 'codex',
|
||||
model: 'legacy-enterprise-custom-model',
|
||||
},
|
||||
],
|
||||
]);
|
||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
||||
|
||||
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(afterSwitch).toMatchObject({
|
||||
runId: secondRun.runId,
|
||||
members: {
|
||||
alice: {
|
||||
providerId: 'anthropic',
|
||||
runtimeModel: 'haiku',
|
||||
pid: 64722,
|
||||
rssBytes: 64_722_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(afterSwitch.members.alice.providerBackendId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps matching-provider custom live models that cannot be inferred', async () => {
|
||||
const teamName = 'provider-switch-anthropic-live-custom-model-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' });
|
||||
await writeMembersMeta(teamName, { primaryProviderId: 'anthropic' });
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
run.runId = `run-${teamName}-anthropic`;
|
||||
run.child = { pid: 64731, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, run);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
alive: true,
|
||||
pid: 64732,
|
||||
providerId: 'anthropic',
|
||||
model: 'enterprise-custom-model',
|
||||
},
|
||||
],
|
||||
]);
|
||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(snapshot.members.alice).toMatchObject({
|
||||
providerId: 'anthropic',
|
||||
runtimeModel: 'enterprise-custom-model',
|
||||
pid: 64732,
|
||||
rssBytes: 64_732_000,
|
||||
});
|
||||
expect(snapshot.members.alice.providerBackendId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a live Codex model even when stale metadata claims the current Anthropic provider', async () => {
|
||||
const teamName = 'provider-switch-anthropic-conflicting-live-model-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' });
|
||||
await writeMembersMeta(teamName, { primaryProviderId: 'anthropic' });
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
run.runId = `run-${teamName}-anthropic`;
|
||||
run.child = { pid: 64801, kill: () => undefined, stdin: { writable: true } };
|
||||
delete run.effectiveMembers[0].providerId;
|
||||
delete run.allEffectiveMembers[0].providerId;
|
||||
trackLiveRun(svc, run);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
alive: true,
|
||||
pid: 64802,
|
||||
providerId: 'anthropic',
|
||||
model: 'gpt-5.4-mini',
|
||||
},
|
||||
],
|
||||
]);
|
||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(snapshot.members.alice).toMatchObject({
|
||||
providerId: 'anthropic',
|
||||
runtimeModel: 'haiku',
|
||||
pid: 64802,
|
||||
rssBytes: 64_802_000,
|
||||
});
|
||||
expect(snapshot.members.alice.providerBackendId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores stale Codex live provider evidence for a current OpenCode side-lane unknown model', async () => {
|
||||
const teamName = 'provider-switch-opencode-stale-live-codex-unknown-model-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' });
|
||||
await writeMembersMeta(teamName, { primaryProviderId: 'anthropic' });
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
run.runId = `run-${teamName}-anthropic`;
|
||||
run.child = { pid: 64761, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, run);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
[
|
||||
'bob',
|
||||
{
|
||||
alive: true,
|
||||
pid: 64762,
|
||||
providerId: 'codex',
|
||||
model: 'legacy-enterprise-custom-model',
|
||||
},
|
||||
],
|
||||
]);
|
||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(snapshot.members.bob).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
runtimeModel: 'opencode/minimax-m2.5-free',
|
||||
pid: 64762,
|
||||
rssBytes: 64_762_000,
|
||||
});
|
||||
expect(snapshot.members.bob.providerBackendId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps matching OpenCode custom live models that cannot be inferred', async () => {
|
||||
const teamName = 'provider-switch-opencode-live-custom-model-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' });
|
||||
await writeMembersMeta(teamName, { primaryProviderId: 'anthropic' });
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
run.runId = `run-${teamName}-anthropic`;
|
||||
run.child = { pid: 64771, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, run);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
[
|
||||
'bob',
|
||||
{
|
||||
alive: true,
|
||||
pid: 64772,
|
||||
providerId: 'opencode',
|
||||
model: 'local-custom-opencode-model',
|
||||
},
|
||||
],
|
||||
]);
|
||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(snapshot.members.bob).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
runtimeModel: 'local-custom-opencode-model',
|
||||
pid: 64772,
|
||||
rssBytes: 64_772_000,
|
||||
});
|
||||
expect(snapshot.members.bob.providerBackendId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores stale Codex live provider evidence for a current Gemini teammate unknown model', async () => {
|
||||
const teamName = 'provider-switch-gemini-stale-live-codex-unknown-model-safe-e2e';
|
||||
await writeMixedTeamConfig({
|
||||
teamName,
|
||||
projectPath,
|
||||
includeGeminiPrimary: true,
|
||||
primaryProviderId: 'anthropic',
|
||||
});
|
||||
await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' });
|
||||
await writeMembersMeta(teamName, {
|
||||
includeGeminiPrimary: true,
|
||||
primaryProviderId: 'anthropic',
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
addGeminiPrimaryToMixedRun(run);
|
||||
run.runId = `run-${teamName}-anthropic`;
|
||||
run.child = { pid: 64781, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, run);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
[
|
||||
'reviewer',
|
||||
{
|
||||
alive: true,
|
||||
pid: 64782,
|
||||
providerId: 'codex',
|
||||
model: 'legacy-enterprise-custom-model',
|
||||
},
|
||||
],
|
||||
]);
|
||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(snapshot.members.reviewer).toMatchObject({
|
||||
providerId: 'gemini',
|
||||
runtimeModel: 'gemini-2.5-flash',
|
||||
pid: 64782,
|
||||
rssBytes: 64_782_000,
|
||||
});
|
||||
expect(snapshot.members.reviewer.providerBackendId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps matching Gemini custom live models that cannot be inferred', async () => {
|
||||
const teamName = 'provider-switch-gemini-live-custom-model-safe-e2e';
|
||||
await writeMixedTeamConfig({
|
||||
teamName,
|
||||
projectPath,
|
||||
includeGeminiPrimary: true,
|
||||
primaryProviderId: 'anthropic',
|
||||
});
|
||||
await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' });
|
||||
await writeMembersMeta(teamName, {
|
||||
includeGeminiPrimary: true,
|
||||
primaryProviderId: 'anthropic',
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const run = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
addGeminiPrimaryToMixedRun(run);
|
||||
run.runId = `run-${teamName}-anthropic`;
|
||||
run.child = { pid: 64791, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, run);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
[
|
||||
'reviewer',
|
||||
{
|
||||
alive: true,
|
||||
pid: 64792,
|
||||
providerId: 'gemini',
|
||||
model: 'enterprise-custom-gemini-model',
|
||||
},
|
||||
],
|
||||
]);
|
||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(snapshot.members.reviewer).toMatchObject({
|
||||
providerId: 'gemini',
|
||||
runtimeModel: 'enterprise-custom-gemini-model',
|
||||
pid: 64792,
|
||||
rssBytes: 64_792_000,
|
||||
});
|
||||
expect(snapshot.members.reviewer.providerBackendId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops stale Codex launch-state backend when the current active run is Anthropic', async () => {
|
||||
const teamName = 'provider-switch-anthropic-stale-launch-state-backend-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath });
|
||||
await writeTeamMeta(teamName, projectPath);
|
||||
await writeMembersMeta(teamName);
|
||||
await writeMixedTeamLaunchState({
|
||||
teamName,
|
||||
members: {
|
||||
alice: mixedMemberState({
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4-mini',
|
||||
laneId: 'primary',
|
||||
laneKind: 'primary',
|
||||
laneOwnerProviderId: 'codex',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
const currentRun = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
currentRun.runId = `run-${teamName}-anthropic`;
|
||||
currentRun.child = { pid: 64671, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, currentRun);
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(snapshot).toMatchObject({
|
||||
runId: currentRun.runId,
|
||||
members: {
|
||||
alice: {
|
||||
providerId: 'anthropic',
|
||||
runtimeModel: 'haiku',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(snapshot.providerBackendId).toBeUndefined();
|
||||
expect(snapshot.members.alice.providerBackendId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restores Codex backend on current Codex relaunch while stale Anthropic metadata remains', async () => {
|
||||
const teamName = 'provider-switch-anthropic-codex-runtime-card-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' });
|
||||
await writeMembersMeta(teamName, { primaryProviderId: 'anthropic' });
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const firstRun = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
firstRun.runId = `run-${teamName}-anthropic`;
|
||||
firstRun.child = { pid: 64631, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, firstRun);
|
||||
|
||||
const beforeSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(beforeSwitch.runId).toBe(firstRun.runId);
|
||||
expect(beforeSwitch.providerBackendId).toBeUndefined();
|
||||
expect(beforeSwitch.members.alice).toMatchObject({
|
||||
providerId: 'anthropic',
|
||||
runtimeModel: 'haiku',
|
||||
});
|
||||
|
||||
await writeMixedTeamConfig({ teamName, projectPath });
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
const secondRun = createMixedLiveRun({ teamName, projectPath });
|
||||
secondRun.runId = `run-${teamName}-codex`;
|
||||
secondRun.child = { pid: 64641, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, secondRun);
|
||||
|
||||
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(afterSwitch).toMatchObject({
|
||||
runId: secondRun.runId,
|
||||
providerBackendId: 'codex-native',
|
||||
members: {
|
||||
'team-lead': { runtimeModel: 'gpt-5.4' },
|
||||
alice: { providerId: 'codex', runtimeModel: 'gpt-5.4-mini' },
|
||||
bob: { providerId: 'opencode', runtimeModel: 'opencode/minimax-m2.5-free' },
|
||||
tom: { providerId: 'opencode', runtimeModel: 'opencode/nemotron-3-super-free' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores stale Anthropic live metadata model for the current Codex provider snapshot', async () => {
|
||||
const teamName = 'provider-switch-codex-stale-live-anthropic-model-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' });
|
||||
await writeMembersMeta(teamName, { primaryProviderId: 'anthropic' });
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const firstRun = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
firstRun.runId = `run-${teamName}-anthropic`;
|
||||
firstRun.child = { pid: 64681, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, firstRun);
|
||||
|
||||
const beforeSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(beforeSwitch.members.alice).toMatchObject({
|
||||
providerId: 'anthropic',
|
||||
runtimeModel: 'haiku',
|
||||
});
|
||||
|
||||
await writeMixedTeamConfig({ teamName, projectPath });
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
const secondRun = createMixedLiveRun({ teamName, projectPath });
|
||||
secondRun.runId = `run-${teamName}-codex`;
|
||||
secondRun.child = { pid: 64691, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, secondRun);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
alive: true,
|
||||
pid: 64692,
|
||||
providerId: 'anthropic',
|
||||
model: 'haiku',
|
||||
},
|
||||
],
|
||||
]);
|
||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
||||
|
||||
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(afterSwitch).toMatchObject({
|
||||
runId: secondRun.runId,
|
||||
providerBackendId: 'codex-native',
|
||||
members: {
|
||||
'team-lead': { runtimeModel: 'gpt-5.4' },
|
||||
alice: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
runtimeModel: 'gpt-5.4-mini',
|
||||
pid: 64692,
|
||||
rssBytes: 64_692_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores stale Anthropic live provider evidence with an unknown model for the current Codex snapshot', async () => {
|
||||
const teamName = 'provider-switch-codex-stale-live-anthropic-unknown-model-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
await writeTeamMeta(teamName, projectPath, { primaryProviderId: 'anthropic' });
|
||||
await writeMembersMeta(teamName, { primaryProviderId: 'anthropic' });
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const firstRun = createMixedLiveRun({ teamName, projectPath, primaryProviderId: 'anthropic' });
|
||||
firstRun.runId = `run-${teamName}-anthropic`;
|
||||
firstRun.child = { pid: 64741, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, firstRun);
|
||||
|
||||
await writeMixedTeamConfig({ teamName, projectPath });
|
||||
TeamConfigReader.invalidateTeam(teamName);
|
||||
const secondRun = createMixedLiveRun({ teamName, projectPath });
|
||||
secondRun.runId = `run-${teamName}-codex`;
|
||||
secondRun.child = { pid: 64751, kill: () => undefined, stdin: { writable: true } };
|
||||
trackLiveRun(svc, secondRun);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
alive: true,
|
||||
pid: 64752,
|
||||
providerId: 'anthropic',
|
||||
model: 'legacy-enterprise-custom-model',
|
||||
},
|
||||
],
|
||||
]);
|
||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
||||
|
||||
const afterSwitch = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(afterSwitch).toMatchObject({
|
||||
runId: secondRun.runId,
|
||||
providerBackendId: 'codex-native',
|
||||
members: {
|
||||
alice: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
runtimeModel: 'gpt-5.4-mini',
|
||||
pid: 64752,
|
||||
rssBytes: 64_752_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a live Anthropic model even when stale metadata claims the current Codex provider', async () => {
|
||||
const teamName = 'provider-switch-codex-conflicting-live-model-safe-e2e';
|
||||
await writeMixedTeamConfig({ teamName, projectPath });
|
||||
await writeTeamMeta(teamName, projectPath);
|
||||
await writeMembersMeta(teamName);
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const run = createMixedLiveRun({ teamName, projectPath });
|
||||
run.runId = `run-${teamName}-codex`;
|
||||
run.child = { pid: 64811, kill: () => undefined, stdin: { writable: true } };
|
||||
delete run.effectiveMembers[0].providerId;
|
||||
delete run.allEffectiveMembers[0].providerId;
|
||||
trackLiveRun(svc, run);
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
alive: true,
|
||||
pid: 64812,
|
||||
providerId: 'codex',
|
||||
model: 'haiku',
|
||||
},
|
||||
],
|
||||
]);
|
||||
(svc as any).readProcessRssBytesByPid = async (pids: number[]) =>
|
||||
new Map(pids.map((pid) => [pid, pid * 1_000]));
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
expect(snapshot).toMatchObject({
|
||||
runId: run.runId,
|
||||
providerBackendId: 'codex-native',
|
||||
members: {
|
||||
alice: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
runtimeModel: 'gpt-5.4-mini',
|
||||
pid: 64812,
|
||||
rssBytes: 64_812_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('refreshes runtime snapshot cache after same-team Anthropic and Gemini mixed relaunch', async () => {
|
||||
const teamName = 'mixed-anthropic-gemini-runtime-cache-relaunch-safe-e2e';
|
||||
await writeMixedTeamConfig({
|
||||
|
|
|
|||
|
|
@ -5374,6 +5374,55 @@ describe('TeamDataService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not show stale Codex backend when Anthropic launch identity overrides legacy team meta', async () => {
|
||||
const harness = createGetTeamDataHarness({
|
||||
config: {
|
||||
name: 'My team',
|
||||
projectPath: '/repo',
|
||||
members: [{ name: 'alice', role: 'Developer' }],
|
||||
},
|
||||
getTeamMeta: async () => ({
|
||||
version: 1,
|
||||
cwd: '/repo',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
launchIdentity: {
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: null,
|
||||
selectedModel: 'opus[1m]',
|
||||
selectedModelKind: 'explicit',
|
||||
resolvedLaunchModel: 'opus[1m]',
|
||||
catalogId: 'opus',
|
||||
catalogSource: 'runtime',
|
||||
catalogFetchedAt: null,
|
||||
selectedEffort: 'low',
|
||||
resolvedEffort: 'low',
|
||||
selectedFastMode: 'inherit',
|
||||
resolvedFastMode: null,
|
||||
fastResolutionReason: null,
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await harness.service.getTeamData('my-team');
|
||||
|
||||
expect(data.members[0]).toMatchObject({
|
||||
name: 'team-lead',
|
||||
providerId: 'anthropic',
|
||||
model: 'opus[1m]',
|
||||
effort: 'low',
|
||||
});
|
||||
expect(data.members[0].providerBackendId).toBeUndefined();
|
||||
const resolverOptions = (
|
||||
harness.resolveMembersSpy.mock.calls[0] as unknown[] | undefined
|
||||
)?.[4] as { leadProviderId?: string; leadProviderBackendId?: string } | undefined;
|
||||
expect(resolverOptions).toMatchObject({ leadProviderId: 'anthropic' });
|
||||
expect(resolverOptions?.leadProviderBackendId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('degrades advisory lookup failure to warning and still completes the snapshot', async () => {
|
||||
const harness = createGetTeamDataHarness({
|
||||
resolveMembers: () => [buildResolvedMember('alice')],
|
||||
|
|
|
|||
|
|
@ -86,7 +86,10 @@ function callWorker(
|
|||
});
|
||||
}
|
||||
|
||||
async function callListTeams(worker: Worker, teamsDir: string): Promise<{
|
||||
async function callListTeams(
|
||||
worker: Worker,
|
||||
teamsDir: string
|
||||
): Promise<{
|
||||
teams: unknown[];
|
||||
diag?: Record<string, unknown>;
|
||||
}> {
|
||||
|
|
@ -107,7 +110,10 @@ async function callListTeams(worker: Worker, teamsDir: string): Promise<{
|
|||
};
|
||||
}
|
||||
|
||||
async function callGetAllTasks(worker: Worker, tasksBase: string): Promise<{
|
||||
async function callGetAllTasks(
|
||||
worker: Worker,
|
||||
tasksBase: string
|
||||
): Promise<{
|
||||
tasks: unknown[];
|
||||
diag?: Record<string, unknown>;
|
||||
}> {
|
||||
|
|
@ -280,6 +286,38 @@ describe('team-fs-worker integration', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('uses lead cwd as the project path when config.projectPath is missing', async () => {
|
||||
const workerPath = await getWorkerPath();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-'));
|
||||
const teamName = 'lead-cwd-project-team';
|
||||
const teamDir = path.join(tempDir, teamName);
|
||||
const projectPath = path.join(tempDir, 'project-321');
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: 'Lead Cwd Project Team',
|
||||
projectPath: null,
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const worker = createWorker(workerPath);
|
||||
try {
|
||||
const { teams } = await callListTeams(worker, tempDir);
|
||||
expect(teams).toHaveLength(1);
|
||||
expect(teams[0]).toMatchObject({
|
||||
teamName,
|
||||
displayName: 'Lead Cwd Project Team',
|
||||
projectPath,
|
||||
});
|
||||
} finally {
|
||||
await worker.terminate();
|
||||
}
|
||||
});
|
||||
|
||||
it('prewarms and reuses unchanged team summaries by fingerprint', async () => {
|
||||
const workerPath = await getWorkerPath();
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-'));
|
||||
|
|
|
|||
|
|
@ -2,11 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||
|
||||
import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver';
|
||||
|
||||
import type {
|
||||
TeamConfig,
|
||||
TeamTask,
|
||||
TeamTaskWithKanban,
|
||||
} from '../../../../src/shared/types/team';
|
||||
import type { TeamConfig, TeamTask, TeamTaskWithKanban } from '../../../../src/shared/types/team';
|
||||
|
||||
describe('TeamMemberResolver', () => {
|
||||
it('builds roster from config + meta + inbox only', () => {
|
||||
|
|
@ -121,6 +117,54 @@ describe('TeamMemberResolver', () => {
|
|||
expect(members.find((member) => member.name === 'alice')?.currentTaskId).toBe('task-active');
|
||||
});
|
||||
|
||||
it('does not leak stale Codex backend metadata into Anthropic members', () => {
|
||||
const resolver = new TeamMemberResolver();
|
||||
const config: TeamConfig = {
|
||||
name: 'Team',
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'opus[1m]',
|
||||
effort: 'low',
|
||||
},
|
||||
{
|
||||
name: 'bob',
|
||||
agentType: 'general-purpose',
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'opus',
|
||||
},
|
||||
],
|
||||
};
|
||||
const metaMembers: TeamConfig['members'] = [
|
||||
{
|
||||
name: 'jack',
|
||||
agentType: 'general-purpose',
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'haiku',
|
||||
},
|
||||
];
|
||||
|
||||
const members = resolver.resolveMembers(config, metaMembers, [], [], {
|
||||
leadProviderId: 'anthropic',
|
||||
leadProviderBackendId: 'codex-native',
|
||||
});
|
||||
|
||||
expect(
|
||||
members
|
||||
.filter((member) => member.providerId === 'anthropic')
|
||||
.map((member) => [member.name, member.providerBackendId])
|
||||
).toEqual([
|
||||
['team-lead', undefined],
|
||||
['bob', undefined],
|
||||
['jack', undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it('filters out "user" pseudo-member even when present in config, meta, or inboxNames', () => {
|
||||
const resolver = new TeamMemberResolver();
|
||||
const config: TeamConfig = {
|
||||
|
|
|
|||
|
|
@ -2546,17 +2546,17 @@ describe('TeamProvisioningService', () => {
|
|||
const svc = new TeamProvisioningService();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' }],
|
||||
})),
|
||||
};
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({ providerBackendId: 'adapter' })),
|
||||
getMeta: vi.fn(async () => ({ providerId: 'codex', providerBackendId: 'adapter' })),
|
||||
};
|
||||
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
|
||||
(svc as any).runs.set('run-1', {
|
||||
runId: 'run-1',
|
||||
child: { pid: 111 },
|
||||
request: { model: 'gpt-5.4', providerBackendId: 'codex-native' },
|
||||
request: { providerId: 'codex', model: 'gpt-5.4', providerBackendId: 'codex-native' },
|
||||
processKilled: false,
|
||||
cancelRequested: false,
|
||||
spawnContext: null,
|
||||
|
|
@ -2574,11 +2574,11 @@ describe('TeamProvisioningService', () => {
|
|||
const svc = new TeamProvisioningService();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' }],
|
||||
})),
|
||||
};
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({ providerBackendId: 'codex-native' })),
|
||||
getMeta: vi.fn(async () => ({ providerId: 'codex', providerBackendId: 'codex-native' })),
|
||||
};
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');
|
||||
|
|
@ -2586,6 +2586,48 @@ describe('TeamProvisioningService', () => {
|
|||
expect(snapshot.providerBackendId).toBe('codex-native');
|
||||
});
|
||||
|
||||
it('drops stale Codex backend metadata for Anthropic runtime snapshots', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', providerId: 'anthropic' }],
|
||||
})),
|
||||
};
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({ providerId: 'anthropic', providerBackendId: 'codex-native' })),
|
||||
};
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');
|
||||
|
||||
expect(snapshot.providerBackendId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses launch identity instead of stale root provider metadata for runtime snapshots', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead', providerId: 'anthropic' }],
|
||||
})),
|
||||
};
|
||||
(svc as any).teamMetaStore = {
|
||||
getMeta: vi.fn(async () => ({
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
fastMode: 'on',
|
||||
launchIdentity: {
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: null,
|
||||
selectedFastMode: 'inherit',
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');
|
||||
|
||||
expect(snapshot.providerBackendId).toBeUndefined();
|
||||
expect(snapshot.fastMode).toBe('inherit');
|
||||
});
|
||||
|
||||
it('falls back to per-pid pidusage reads when batched sampling fails', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).configReader = {
|
||||
|
|
@ -13193,6 +13235,81 @@ describe('TeamProvisioningService', () => {
|
|||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('starts an Anthropic team without injecting lead effort into explicit teammate models', async () => {
|
||||
allowConsoleLogs();
|
||||
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
||||
vi.mocked(spawnCli).mockReturnValue(createRunningChild() as any);
|
||||
|
||||
const { svc } = createSafeLaunchService();
|
||||
(svc as any).resolveAndValidateLaunchIdentity = vi.fn(async () => ({
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
selectedModel: 'sonnet',
|
||||
selectedModelKind: 'explicit',
|
||||
resolvedLaunchModel: 'sonnet',
|
||||
catalogId: 'sonnet',
|
||||
catalogSource: 'test',
|
||||
catalogFetchedAt: '2026-05-17T00:00:00.000Z',
|
||||
selectedEffort: 'low',
|
||||
resolvedEffort: 'low',
|
||||
selectedFastMode: null,
|
||||
resolvedFastMode: null,
|
||||
fastResolutionReason: null,
|
||||
}));
|
||||
|
||||
const { runId } = await svc.createTeam(
|
||||
{
|
||||
teamName: 'safe-anthropic-explicit-model-effort-launch',
|
||||
cwd: tempClaudeRoot,
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
members: [
|
||||
{
|
||||
name: 'jack',
|
||||
role: 'Reviewer',
|
||||
providerId: 'anthropic',
|
||||
model: 'haiku',
|
||||
},
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Developer',
|
||||
},
|
||||
],
|
||||
},
|
||||
() => {}
|
||||
);
|
||||
|
||||
const spawnArgs = vi.mocked(spawnCli).mock.calls[0]?.[1] as string[];
|
||||
expect(spawnArgs).toEqual(expect.arrayContaining(['--model', 'sonnet', '--effort', 'low']));
|
||||
const bootstrapSpec = readBootstrapSpecFromSpawnArgs(spawnArgs);
|
||||
expect(bootstrapSpec).toMatchObject({
|
||||
mode: 'create',
|
||||
team: {
|
||||
name: 'safe-anthropic-explicit-model-effort-launch',
|
||||
cwd: tempClaudeRoot,
|
||||
},
|
||||
});
|
||||
expect(bootstrapSpec.members).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'jack',
|
||||
provider: 'anthropic',
|
||||
model: 'haiku',
|
||||
role: 'Reviewer',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
provider: 'anthropic',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
role: 'Developer',
|
||||
}),
|
||||
]);
|
||||
expect(bootstrapSpec.members[0]).not.toHaveProperty('effort');
|
||||
|
||||
await svc.cancelProvisioning(runId);
|
||||
});
|
||||
|
||||
it('routes a pure OpenCode team directly through the runtime adapter without spawning the CLI lane', async () => {
|
||||
allowConsoleLogs();
|
||||
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
|
||||
|
|
@ -14465,13 +14582,14 @@ describe('TeamProvisioningService', () => {
|
|||
],
|
||||
};
|
||||
|
||||
expect((svc as any).buildTeammatePermissionUpdatedInput('AskUserQuestion', toolInput, ''))
|
||||
.toEqual({
|
||||
...toolInput,
|
||||
answers: {
|
||||
'Anything else?': '',
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(svc as any).buildTeammatePermissionUpdatedInput('AskUserQuestion', toolInput, '')
|
||||
).toEqual({
|
||||
...toolInput,
|
||||
answers: {
|
||||
'Anything else?': '',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sends teammate AskUserQuestion permission responses to the teammate inbox', async () => {
|
||||
|
|
|
|||
|
|
@ -477,6 +477,199 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
expect(result.args).toContain('--anthropic-safe-passthrough');
|
||||
});
|
||||
|
||||
it('does not inherit lead effort for an Anthropic teammate with an explicit model', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const result = await (svc as any).materializeEffectiveTeamMemberSpecs({
|
||||
claudePath: '/fake/claude',
|
||||
cwd: tempRoot,
|
||||
members: [{ name: 'jack', providerId: 'anthropic', model: 'haiku' }, { name: 'alice' }],
|
||||
defaults: {
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
{ name: 'jack', providerId: 'anthropic', model: 'haiku', effort: undefined },
|
||||
{ name: 'alice', providerId: 'anthropic', model: 'sonnet', effort: 'low' },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
label: 'inherits lead model and effort when teammate leaves runtime unset',
|
||||
defaults: { providerId: 'anthropic', model: 'sonnet', effort: 'low' },
|
||||
members: [{ name: 'alice' }],
|
||||
expected: [{ name: 'alice', providerId: 'anthropic', model: 'sonnet', effort: 'low' }],
|
||||
},
|
||||
{
|
||||
label: 'keeps effort unset when teammate selects a different Anthropic model',
|
||||
defaults: { providerId: 'anthropic', model: 'sonnet', effort: 'low' },
|
||||
members: [{ name: 'jack', providerId: 'anthropic', model: 'haiku' }],
|
||||
expected: [{ name: 'jack', providerId: 'anthropic', model: 'haiku', effort: undefined }],
|
||||
},
|
||||
{
|
||||
label: 'keeps effort unset even when teammate explicitly selects the same Anthropic model',
|
||||
defaults: { providerId: 'anthropic', model: 'sonnet', effort: 'low' },
|
||||
members: [{ name: 'bob', providerId: 'anthropic', model: 'sonnet' }],
|
||||
expected: [{ name: 'bob', providerId: 'anthropic', model: 'sonnet', effort: undefined }],
|
||||
},
|
||||
{
|
||||
label: 'preserves teammate explicit effort with an explicit Anthropic model',
|
||||
defaults: { providerId: 'anthropic', model: 'sonnet', effort: 'low' },
|
||||
members: [{ name: 'eve', providerId: 'anthropic', model: 'haiku', effort: 'medium' }],
|
||||
expected: [{ name: 'eve', providerId: 'anthropic', model: 'haiku', effort: 'medium' }],
|
||||
},
|
||||
{
|
||||
label: 'does not inherit lead effort across providers',
|
||||
defaults: { providerId: 'anthropic', model: 'sonnet', effort: 'low' },
|
||||
members: [{ name: 'tom', providerId: 'codex', model: 'gpt-5.4' }],
|
||||
expected: [{ name: 'tom', providerId: 'codex', model: 'gpt-5.4', effort: undefined }],
|
||||
},
|
||||
{
|
||||
label: 'resolves secondary non-Anthropic default model without inheriting lead effort',
|
||||
defaults: { providerId: 'anthropic', model: 'sonnet', effort: 'low' },
|
||||
members: [{ name: 'sam', providerId: 'codex' }],
|
||||
expected: [{ name: 'sam', providerId: 'codex', model: 'gpt-5.4-mini', effort: undefined }],
|
||||
},
|
||||
{
|
||||
label: 'does not inherit Codex lead effort into an Anthropic teammate model',
|
||||
defaults: { providerId: 'codex', model: 'gpt-5.5', effort: 'low' },
|
||||
members: [{ name: 'zoe', providerId: 'anthropic', model: 'haiku' }],
|
||||
expected: [{ name: 'zoe', providerId: 'anthropic', model: 'haiku', effort: undefined }],
|
||||
},
|
||||
])('$label', async ({ defaults, members, expected }) => {
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const result = await (svc as any).materializeEffectiveTeamMemberSpecs({
|
||||
claudePath: '/fake/claude',
|
||||
cwd: tempRoot,
|
||||
members,
|
||||
defaults,
|
||||
});
|
||||
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
|
||||
it('validates the Sonnet low lead plus explicit Haiku teammate launch matrix', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const facts = {
|
||||
defaultModel: 'sonnet',
|
||||
modelIds: new Set(['sonnet', 'haiku']),
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'anthropic',
|
||||
source: 'anthropic-models-api',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-05-17T00:00:00.000Z',
|
||||
staleAt: '2026-05-17T00:01:00.000Z',
|
||||
defaultModelId: 'sonnet',
|
||||
defaultLaunchModel: 'sonnet',
|
||||
models: [
|
||||
{
|
||||
id: 'sonnet',
|
||||
launchModel: 'sonnet',
|
||||
displayName: 'Sonnet 4.6',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
defaultReasoningEffort: 'medium',
|
||||
supportsFastMode: false,
|
||||
inputModalities: ['text', 'image'],
|
||||
supportsPersonality: false,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'anthropic-models-api',
|
||||
},
|
||||
{
|
||||
id: 'haiku',
|
||||
launchModel: 'haiku',
|
||||
displayName: 'Haiku 4.5',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: [],
|
||||
defaultReasoningEffort: null,
|
||||
supportsFastMode: false,
|
||||
inputModalities: ['text', 'image'],
|
||||
supportsPersonality: false,
|
||||
isDefault: false,
|
||||
upgrade: false,
|
||||
source: 'anthropic-models-api',
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
runtimeCapabilities: {
|
||||
modelCatalog: { dynamic: true, source: 'anthropic-models-api' },
|
||||
reasoningEffort: {
|
||||
supported: true,
|
||||
values: ['low', 'medium', 'high'],
|
||||
configPassthrough: true,
|
||||
},
|
||||
fastMode: {
|
||||
supported: true,
|
||||
available: true,
|
||||
reason: null,
|
||||
source: 'runtime',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const materializedMembers = await (svc as any).materializeEffectiveTeamMemberSpecs({
|
||||
claudePath: '/fake/claude',
|
||||
cwd: tempRoot,
|
||||
members: [{ name: 'jack', providerId: 'anthropic', model: 'haiku' }, { name: 'alice' }],
|
||||
defaults: {
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
},
|
||||
});
|
||||
|
||||
expect(materializedMembers).toEqual([
|
||||
{ name: 'jack', providerId: 'anthropic', model: 'haiku', effort: undefined },
|
||||
{ name: 'alice', providerId: 'anthropic', model: 'sonnet', effort: 'low' },
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
(svc as any).validateRuntimeLaunchSelection({
|
||||
actorLabel: 'Team lead',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
limitContext: false,
|
||||
facts,
|
||||
})
|
||||
).not.toThrow();
|
||||
|
||||
for (const member of materializedMembers) {
|
||||
expect(() =>
|
||||
(svc as any).validateRuntimeLaunchSelection({
|
||||
actorLabel: `Member ${member.name}`,
|
||||
providerId: member.providerId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
limitContext: false,
|
||||
facts,
|
||||
})
|
||||
).not.toThrow();
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
(svc as any).validateRuntimeLaunchSelection({
|
||||
actorLabel: 'Member jack',
|
||||
providerId: 'anthropic',
|
||||
model: 'haiku',
|
||||
effort: 'low',
|
||||
limitContext: false,
|
||||
facts,
|
||||
})
|
||||
).toThrow('does not support it in the current runtime');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await removeTempRoot(tempRoot);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -83,8 +83,15 @@ vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({
|
||||
SidebarTaskItem: ({ task }: { task: GlobalTask }) =>
|
||||
React.createElement('div', { 'data-testid': 'sidebar-task-item' }, task.subject),
|
||||
SidebarTaskItem: ({ task, hideProjectName }: { task: GlobalTask; hideProjectName?: boolean }) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-testid': 'sidebar-task-item',
|
||||
'data-hide-project-name': hideProjectName ? 'true' : 'false',
|
||||
},
|
||||
task.subject
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../src/renderer/components/sidebar/TaskFiltersPopover', () => ({
|
||||
|
|
@ -245,6 +252,31 @@ describe('GlobalTaskList project grouping', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('hides project labels in task cards when grouped by project', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.globalTasks = [makeTask(1), makeTask(2)];
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(GlobalTaskList));
|
||||
await flushMicrotasks();
|
||||
});
|
||||
|
||||
expect(
|
||||
Array.from(host.querySelectorAll('[data-testid="sidebar-task-item"]')).map((node) =>
|
||||
node.getAttribute('data-hide-project-name')
|
||||
)
|
||||
).toEqual(['true', 'true']);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flushMicrotasks();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the hard visible limit when new tasks arrive after expansion', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1));
|
||||
|
|
|
|||
|
|
@ -30,7 +30,8 @@ vi.mock('../../../../src/renderer/hooks/useTheme', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('../../../../src/renderer/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children),
|
||||
Tooltip: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipTrigger: ({ children }: React.PropsWithChildren) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipContent: ({ children }: React.PropsWithChildren) =>
|
||||
|
|
@ -62,7 +63,7 @@ vi.mock('../../../../src/shared/utils/reviewState', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('zustand/react/shallow', () => ({
|
||||
useShallow: <T,>(selector: T) => selector,
|
||||
useShallow: <T>(selector: T) => selector,
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => {
|
||||
|
|
@ -139,4 +140,47 @@ describe('SidebarTaskItem unread styling', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('animates the in-progress status icon', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(SidebarTaskItem, { task: makeTask() }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('svg')?.getAttribute('class')).toContain('animate-spin');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('can hide the project label when the parent already groups by project', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(SidebarTaskItem, { task: makeTask(), hideProjectName: true })
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain('hookplex');
|
||||
expect(host.textContent).toContain('alice');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -81,6 +81,25 @@ vi.mock('@features/codex-account/renderer', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
const useVirtualizerMock = vi.fn(
|
||||
(options: { count: number }) =>
|
||||
({
|
||||
getVirtualItems: () =>
|
||||
Array.from({ length: Math.min(options.count, 9) }, (_, index) => ({
|
||||
index,
|
||||
key: index,
|
||||
start: index * 92,
|
||||
size: 92,
|
||||
})),
|
||||
getTotalSize: () => options.count * 92,
|
||||
measureElement: () => undefined,
|
||||
}) as const
|
||||
);
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: (options: { count: number }) => useVirtualizerMock(options),
|
||||
}));
|
||||
|
||||
import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
|
||||
describe('TeamModelSelector disabled Codex models', () => {
|
||||
|
|
@ -96,6 +115,7 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
codexAccountHookState.startChatgptLogin.mockClear();
|
||||
codexAccountHookState.cancelChatgptLogin.mockClear();
|
||||
codexAccountHookState.logout.mockClear();
|
||||
useVirtualizerMock.mockClear();
|
||||
});
|
||||
|
||||
it('shows only Default while Codex runtime models are still loading', async () => {
|
||||
|
|
@ -288,7 +308,7 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
expect(host.textContent).toContain('openai/gpt-oss-120b:free');
|
||||
expect(host.textContent).toContain('big-pickle');
|
||||
expect(host.textContent).toContain('qwen/qwen3-coder-plus');
|
||||
expect(host.textContent).toContain('Unavailable in OpenCode');
|
||||
expect(host.textContent).toContain('Not verified in OpenCode');
|
||||
expect(host.textContent).toContain('openai/gpt-oss-20b:free');
|
||||
expect(host.textContent).toContain('Not recommended');
|
||||
const groupLabels = Array.from(
|
||||
|
|
@ -329,6 +349,65 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('virtualizes large OpenCode model lists instead of rendering every model tile', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const models = Array.from(
|
||||
{ length: 160 },
|
||||
(_, index) => `openrouter/test/model-${String(index).padStart(3, '0')}`
|
||||
);
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
authMethod: 'opencode_managed',
|
||||
backend: {
|
||||
kind: 'opencode-cli',
|
||||
label: 'OpenCode CLI',
|
||||
endpointLabel: 'opencode',
|
||||
},
|
||||
authenticated: true,
|
||||
supported: true,
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
},
|
||||
models,
|
||||
modelVerificationState: 'idle',
|
||||
modelAvailability: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(TeamModelSelector, {
|
||||
providerId: 'opencode',
|
||||
onProviderChange: () => undefined,
|
||||
value: '',
|
||||
onValueChange: () => undefined,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const virtualizerOptions = useVirtualizerMock.mock.calls.at(-1)?.[0] as
|
||||
| { count: number }
|
||||
| undefined;
|
||||
expect(virtualizerOptions?.count).toBeGreaterThan(80);
|
||||
expect(host.textContent).toContain('OpenRouter');
|
||||
expect(host.textContent).toContain('test/model-000');
|
||||
expect(host.textContent).not.toContain('test/model-159');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows short-lived OpenCode preflight failures as unavailable model tiles', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
|
|
@ -397,7 +476,7 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows short-lived OpenCode preflight notes as selectable issue tiles', async () => {
|
||||
it('shows short-lived OpenCode preflight notes as selectable advisory tiles', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
|
|
@ -434,8 +513,8 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
onProviderChange: () => undefined,
|
||||
value: '',
|
||||
onValueChange,
|
||||
modelIssueReasonByValue: {
|
||||
'openai/gpt-5.4': 'Model verification timed out',
|
||||
modelAdvisoryReasonByValue: {
|
||||
'opencode/big-pickle': 'big-pickle - ping not confirmed',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
@ -443,19 +522,21 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
});
|
||||
|
||||
const issueButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('GPT-5.4')
|
||||
button.textContent?.includes('big-pickle')
|
||||
);
|
||||
expect(issueButton).not.toBeNull();
|
||||
expect(issueButton?.getAttribute('aria-disabled')).toBe('false');
|
||||
expect(issueButton?.textContent).toContain('Issue');
|
||||
expect(issueButton?.getAttribute('title')).toContain('Model verification timed out');
|
||||
expect(issueButton?.textContent).toContain('Ping not confirmed');
|
||||
expect(issueButton?.className).toContain('border-amber-300/35');
|
||||
expect(issueButton?.className).not.toContain('border-red-500');
|
||||
expect(issueButton?.getAttribute('title')).toContain('ping not confirmed');
|
||||
|
||||
await act(async () => {
|
||||
issueButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('openai/gpt-5.4');
|
||||
expect(onValueChange).toHaveBeenCalledWith('opencode/big-pickle');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -419,6 +419,10 @@ vi.mock('@renderer/components/team/dialogs/providerPrepareCacheKey', () => ({
|
|||
vi.mock('@renderer/components/team/dialogs/providerPrepareDiagnostics', () => ({
|
||||
buildReusableProviderPrepareModelResults: () => ({}),
|
||||
getProviderPrepareCachedSnapshot: () => ({ status: 'checking', details: [] }),
|
||||
mergeReusableProviderPrepareModelResults: (
|
||||
existing: Record<string, unknown> | null | undefined,
|
||||
next: Record<string, unknown>
|
||||
) => ({ ...(existing ?? {}), ...next }),
|
||||
runProviderPrepareDiagnostics: vi.fn(async () => ({
|
||||
status: 'ready',
|
||||
warnings: [],
|
||||
|
|
|
|||
|
|
@ -295,6 +295,56 @@ describe('resolveLaunchDialogPrefill', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not carry a stale Codex backend into an Anthropic lead prefill', () => {
|
||||
const members = [
|
||||
{
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
providerId: 'anthropic',
|
||||
model: 'haiku',
|
||||
effort: 'low',
|
||||
},
|
||||
] as ResolvedTeamMember[];
|
||||
|
||||
const result = resolveLaunchDialogPrefill({
|
||||
members,
|
||||
savedRequest: {
|
||||
teamName: 'signal-ops-22',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
members: [],
|
||||
} as TeamCreateRequest,
|
||||
previousLaunchParams: {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
},
|
||||
multimodelEnabled: true,
|
||||
storedProviderId: 'codex',
|
||||
storedEffort: 'medium',
|
||||
storedFastMode: 'inherit',
|
||||
storedLimitContext: false,
|
||||
getStoredModel: createStoredModelGetter({
|
||||
anthropic: 'sonnet',
|
||||
codex: 'gpt-5.4',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
model: 'haiku',
|
||||
effort: 'low',
|
||||
fastMode: 'inherit',
|
||||
limitContext: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves literal [1m] suffixes for non-anthropic providers', () => {
|
||||
const result = resolveLaunchDialogPrefill({
|
||||
members: [],
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import {
|
||||
buildReusableProviderPrepareModelResults,
|
||||
mergeReusableProviderPrepareModelResults,
|
||||
runProviderPrepareDiagnostics,
|
||||
} from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
|
||||
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection';
|
||||
|
|
@ -53,6 +54,75 @@ describe('runProviderPrepareDiagnostics', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('merges reusable model results without dropping earlier cache entries', () => {
|
||||
expect(
|
||||
mergeReusableProviderPrepareModelResults(
|
||||
{
|
||||
'gpt-5.4': {
|
||||
status: 'ready',
|
||||
line: '5.4 - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
'gpt-5.4-mini': {
|
||||
status: 'ready',
|
||||
line: '5.4 Mini - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
'gpt-5.3-codex': {
|
||||
status: 'notes',
|
||||
line: '5.3 Codex - check failed - Model verification timed out',
|
||||
warningLine: '5.3 Codex - check failed - Model verification timed out',
|
||||
},
|
||||
}
|
||||
)
|
||||
).toEqual({
|
||||
'gpt-5.4': {
|
||||
status: 'ready',
|
||||
line: '5.4 - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
'gpt-5.4-mini': {
|
||||
status: 'ready',
|
||||
line: '5.4 Mini - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('removes a stale reusable model result when the latest result is advisory', () => {
|
||||
expect(
|
||||
mergeReusableProviderPrepareModelResults(
|
||||
{
|
||||
'gpt-5.4': {
|
||||
status: 'ready',
|
||||
line: '5.4 - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
'gpt-5.2-codex': {
|
||||
status: 'failed',
|
||||
line: '5.2 Codex - unavailable - Not available on this Codex native runtime',
|
||||
warningLine: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
'gpt-5.2-codex': {
|
||||
status: 'notes',
|
||||
line: '5.2 Codex - check failed - Model verification timed out',
|
||||
warningLine: '5.2 Codex - check failed - Model verification timed out',
|
||||
},
|
||||
}
|
||||
)
|
||||
).toEqual({
|
||||
'gpt-5.4': {
|
||||
status: 'ready',
|
||||
line: '5.4 - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns a failed provider result immediately when runtime preflight fails', async () => {
|
||||
const prepareProvisioning = vi
|
||||
.fn<
|
||||
|
|
|
|||
|
|
@ -49,9 +49,10 @@ describe('providerPrepareShortLivedCache', () => {
|
|||
cacheKey: 'key-1',
|
||||
})
|
||||
).toEqual({
|
||||
modelIssueReasonByValue: {
|
||||
modelAdvisoryReasonByValue: {
|
||||
'opencode/nemotron-3-super-free': 'timed out',
|
||||
},
|
||||
modelIssueReasonByValue: {},
|
||||
modelUnavailableReasonByValue: {},
|
||||
});
|
||||
});
|
||||
|
|
@ -105,6 +106,7 @@ describe('providerPrepareShortLivedCache', () => {
|
|||
cacheKey: 'key-4',
|
||||
})
|
||||
).toEqual({
|
||||
modelAdvisoryReasonByValue: {},
|
||||
modelIssueReasonByValue: {},
|
||||
modelUnavailableReasonByValue: {
|
||||
'openai/gpt-5.4': 'OpenCode provider authentication failed',
|
||||
|
|
@ -142,11 +144,100 @@ describe('providerPrepareShortLivedCache', () => {
|
|||
cacheKey: 'key-5',
|
||||
})
|
||||
).toEqual({
|
||||
modelAdvisoryReasonByValue: {},
|
||||
modelIssueReasonByValue: {},
|
||||
modelUnavailableReasonByValue: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a short-lived successful result when a later advisory targets the same model', () => {
|
||||
storeShortLivedProviderPrepareModelResults({
|
||||
providerId: 'opencode',
|
||||
cacheKey: 'key-7',
|
||||
modelResultsById: {
|
||||
'openai/gpt-5.4': {
|
||||
status: 'ready',
|
||||
line: 'GPT-5.4 - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
storeShortLivedProviderPrepareModelResults({
|
||||
providerId: 'opencode',
|
||||
cacheKey: 'key-7',
|
||||
modelResultsById: {
|
||||
'openai/gpt-5.4': {
|
||||
status: 'notes',
|
||||
line: 'GPT-5.4 - check failed - Model verification timed out',
|
||||
warningLine: 'GPT-5.4 - check failed - Model verification timed out',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
getShortLivedProviderPrepareModelResults({
|
||||
providerId: 'opencode',
|
||||
cacheKey: 'key-7',
|
||||
})
|
||||
).toEqual({});
|
||||
expect(
|
||||
getShortLivedProviderPrepareModelIssueReasons({
|
||||
providerId: 'opencode',
|
||||
cacheKey: 'key-7',
|
||||
})
|
||||
).toEqual({
|
||||
modelAdvisoryReasonByValue: {
|
||||
'openai/gpt-5.4': 'Model verification timed out',
|
||||
},
|
||||
modelIssueReasonByValue: {},
|
||||
modelUnavailableReasonByValue: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a short-lived successful result when a later failure targets the same model', () => {
|
||||
storeShortLivedProviderPrepareModelResults({
|
||||
providerId: 'opencode',
|
||||
cacheKey: 'key-8',
|
||||
modelResultsById: {
|
||||
'openai/gpt-5.4': {
|
||||
status: 'ready',
|
||||
line: 'GPT-5.4 - verified',
|
||||
warningLine: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
storeShortLivedProviderPrepareModelResults({
|
||||
providerId: 'opencode',
|
||||
cacheKey: 'key-8',
|
||||
modelResultsById: {
|
||||
'openai/gpt-5.4': {
|
||||
status: 'failed',
|
||||
line: 'GPT-5.4 - unavailable - OpenCode provider authentication failed',
|
||||
warningLine: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
getShortLivedProviderPrepareModelResults({
|
||||
providerId: 'opencode',
|
||||
cacheKey: 'key-8',
|
||||
})
|
||||
).toEqual({});
|
||||
expect(
|
||||
getShortLivedProviderPrepareModelIssueReasons({
|
||||
providerId: 'opencode',
|
||||
cacheKey: 'key-8',
|
||||
})
|
||||
).toEqual({
|
||||
modelAdvisoryReasonByValue: {},
|
||||
modelIssueReasonByValue: {},
|
||||
modelUnavailableReasonByValue: {
|
||||
'openai/gpt-5.4': 'OpenCode provider authentication failed',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('expires short-lived OpenCode issues after the issue TTL', () => {
|
||||
vi.useFakeTimers();
|
||||
storeShortLivedProviderPrepareModelResults({
|
||||
|
|
@ -169,6 +260,7 @@ describe('providerPrepareShortLivedCache', () => {
|
|||
cacheKey: 'key-6',
|
||||
})
|
||||
).toEqual({
|
||||
modelAdvisoryReasonByValue: {},
|
||||
modelIssueReasonByValue: {},
|
||||
modelUnavailableReasonByValue: {},
|
||||
});
|
||||
|
|
@ -199,6 +291,7 @@ describe('providerPrepareShortLivedCache', () => {
|
|||
cacheKey: 'key-3',
|
||||
})
|
||||
).toEqual({
|
||||
modelAdvisoryReasonByValue: {},
|
||||
modelIssueReasonByValue: {},
|
||||
modelUnavailableReasonByValue: {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -63,4 +63,42 @@ describe('executeTeamRelaunch', () => {
|
|||
expect(calls).toEqual(['replace', 'launch']);
|
||||
expect(stopTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps changed relaunch provider and model in the replacement and launch payloads', async () => {
|
||||
const calls: string[] = [];
|
||||
const stopTeam = vi.fn(async () => {
|
||||
calls.push('stop');
|
||||
});
|
||||
const replaceMembers = vi.fn(async () => {
|
||||
calls.push('replace');
|
||||
});
|
||||
const launchTeam = vi.fn(async () => {
|
||||
calls.push('launch');
|
||||
});
|
||||
const request = {
|
||||
teamName: 'team-alpha',
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'anthropic' as const,
|
||||
model: 'sonnet',
|
||||
effort: 'low' as const,
|
||||
};
|
||||
const members = [
|
||||
{ name: 'alice', role: 'Reviewer' },
|
||||
{ name: 'jack', role: 'Builder', providerId: 'anthropic' as const, model: 'sonnet' },
|
||||
];
|
||||
|
||||
await executeTeamRelaunch({
|
||||
teamName: 'team-alpha',
|
||||
isTeamAlive: true,
|
||||
request,
|
||||
members,
|
||||
stopTeam,
|
||||
replaceMembers,
|
||||
launchTeam,
|
||||
});
|
||||
|
||||
expect(calls).toEqual(['stop', 'replace', 'launch']);
|
||||
expect(replaceMembers).toHaveBeenCalledWith('team-alpha', { members });
|
||||
expect(launchTeam).toHaveBeenCalledWith(request);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -313,6 +313,9 @@ describe('MemberCard starting-state visuals', () => {
|
|||
|
||||
expect(host.textContent).toContain('registered');
|
||||
expect(host.querySelector('[aria-label="registered"]')).not.toBeNull();
|
||||
expect(host.firstElementChild?.className).toContain('-mx-[calc(1rem-5px)]');
|
||||
expect(host.firstElementChild?.className).toContain('px-[calc(1rem-5px)]');
|
||||
expect(host.querySelector('[role="button"]')?.className).toContain('-mx-[calc(1rem-5px)]');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -476,7 +479,6 @@ describe('MemberCard starting-state visuals', () => {
|
|||
expect(avatarRing?.style.borderColor).toBe('#3b82f6');
|
||||
expect(clickableCard?.style.borderLeft).toBe('');
|
||||
expect(clickableCard?.style.background).toBe('');
|
||||
expect(clickableCard?.className).not.toContain('px-');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -134,6 +134,7 @@ describe('MemberList spawn-status memoization', () => {
|
|||
React.createElement(MemberList, {
|
||||
members: [],
|
||||
expectedTeammateCount: 2,
|
||||
isRosterLoading: true,
|
||||
isTeamAlive: false,
|
||||
})
|
||||
);
|
||||
|
|
@ -168,6 +169,7 @@ describe('MemberList spawn-status memoization', () => {
|
|||
},
|
||||
],
|
||||
expectedTeammateCount: 2,
|
||||
isRosterLoading: true,
|
||||
isTeamAlive: false,
|
||||
})
|
||||
);
|
||||
|
|
@ -185,6 +187,98 @@ describe('MemberList spawn-status memoization', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not keep a skeleton for a settled count-only roster summary', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberList, {
|
||||
members: [],
|
||||
expectedTeammateCount: 2,
|
||||
isRosterLoading: false,
|
||||
isTeamProvisioning: false,
|
||||
isTeamAlive: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[aria-label="Loading team members"]')).toBeNull();
|
||||
expect(host.textContent).toContain('Member roster unavailable');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not keep a skeleton for an offline team with stale settling metadata', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberList, {
|
||||
members: [],
|
||||
expectedTeammateCount: 2,
|
||||
isLaunchSettling: true,
|
||||
isRosterLoading: false,
|
||||
isTeamProvisioning: false,
|
||||
isTeamAlive: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[aria-label="Loading team members"]')).toBeNull();
|
||||
expect(host.textContent).toContain('Member roster unavailable');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the lead card after loading settles even when summary still expects teammates', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberList, {
|
||||
members: [
|
||||
{
|
||||
...member,
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
role: 'Team Lead',
|
||||
},
|
||||
],
|
||||
expectedTeammateCount: 2,
|
||||
isRosterLoading: false,
|
||||
isTeamProvisioning: false,
|
||||
isTeamAlive: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[aria-label="Loading team members"]')).toBeNull();
|
||||
expect(host.querySelector('[data-testid="member-team-lead"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('rerenders cards when only the hard failure reason changes', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ describe('members editor editable input filtering', () => {
|
|||
name: 'bob',
|
||||
agentType: 'developer',
|
||||
},
|
||||
] satisfies Array<Pick<ResolvedTeamMember, 'name' | 'agentType'>>;
|
||||
] satisfies Pick<ResolvedTeamMember, 'name' | 'agentType'>[];
|
||||
|
||||
expect(filterEditableMemberInputs(members).map((member) => member.name)).toEqual([
|
||||
'alice',
|
||||
|
|
@ -57,9 +57,10 @@ describe('members editor editable input filtering', () => {
|
|||
model: 'gpt-5.4-mini',
|
||||
effort: 'medium',
|
||||
},
|
||||
] satisfies Array<
|
||||
Pick<ResolvedTeamMember, 'name' | 'agentType' | 'providerId' | 'model' | 'effort'>
|
||||
>;
|
||||
] satisfies Pick<
|
||||
ResolvedTeamMember,
|
||||
'name' | 'agentType' | 'providerId' | 'model' | 'effort'
|
||||
>[];
|
||||
|
||||
const drafts = createMemberDraftsFromInputs(filterEditableMemberInputs(members));
|
||||
expect(drafts).toHaveLength(1);
|
||||
|
|
@ -103,6 +104,210 @@ describe('members editor editable input filtering', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('drops hidden stale teammate backend when exporting against a new inherited provider', () => {
|
||||
const drafts = createMemberDraftsFromInputs(
|
||||
filterEditableMemberInputs([
|
||||
{
|
||||
name: 'alice',
|
||||
agentType: 'reviewer',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'haiku',
|
||||
},
|
||||
] as any)
|
||||
);
|
||||
|
||||
const exported = buildMembersFromDrafts(drafts, {
|
||||
inheritedProviderId: 'anthropic',
|
||||
});
|
||||
|
||||
expect(exported).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
model: 'haiku',
|
||||
}),
|
||||
]);
|
||||
expect(exported[0]).not.toHaveProperty('providerId');
|
||||
expect(exported[0]).not.toHaveProperty('providerBackendId');
|
||||
});
|
||||
|
||||
it('keeps hidden teammate backend when it matches the inherited provider', () => {
|
||||
const drafts = createMemberDraftsFromInputs(
|
||||
filterEditableMemberInputs([
|
||||
{
|
||||
name: 'alice',
|
||||
agentType: 'reviewer',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4-mini',
|
||||
},
|
||||
] as any)
|
||||
);
|
||||
|
||||
const exported = buildMembersFromDrafts(drafts, {
|
||||
inheritedProviderId: 'codex',
|
||||
});
|
||||
|
||||
expect(exported).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4-mini',
|
||||
}),
|
||||
]);
|
||||
expect(exported[0]).not.toHaveProperty('providerId');
|
||||
});
|
||||
|
||||
it('does not synthesize hidden teammate backend from inherited provider defaults', () => {
|
||||
const drafts = createMemberDraftsFromInputs(
|
||||
filterEditableMemberInputs([
|
||||
{
|
||||
name: 'alice',
|
||||
agentType: 'reviewer',
|
||||
model: 'gpt-5.4-mini',
|
||||
},
|
||||
] as any)
|
||||
);
|
||||
|
||||
const exported = buildMembersFromDrafts(drafts, {
|
||||
inheritedProviderId: 'codex',
|
||||
});
|
||||
|
||||
expect(exported).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
model: 'gpt-5.4-mini',
|
||||
}),
|
||||
]);
|
||||
expect(exported[0]).not.toHaveProperty('providerId');
|
||||
expect(exported[0]).not.toHaveProperty('providerBackendId');
|
||||
});
|
||||
|
||||
it('drops inherited teammate model when its inferred provider conflicts', () => {
|
||||
const drafts = createMemberDraftsFromInputs(
|
||||
filterEditableMemberInputs([
|
||||
{
|
||||
name: 'alice',
|
||||
agentType: 'reviewer',
|
||||
model: 'gpt-5.4-mini',
|
||||
effort: 'max',
|
||||
},
|
||||
] as any)
|
||||
);
|
||||
|
||||
const exported = buildMembersFromDrafts(drafts, {
|
||||
inheritedProviderId: 'anthropic',
|
||||
});
|
||||
|
||||
expect(exported).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
effort: 'max',
|
||||
}),
|
||||
]);
|
||||
expect(exported[0]).not.toHaveProperty('model');
|
||||
});
|
||||
|
||||
it('drops inherited teammate effort when selected provider does not support it', () => {
|
||||
const drafts = createMemberDraftsFromInputs(
|
||||
filterEditableMemberInputs([
|
||||
{
|
||||
name: 'alice',
|
||||
agentType: 'reviewer',
|
||||
model: 'gpt-5.4-mini',
|
||||
effort: 'max',
|
||||
},
|
||||
] as any)
|
||||
);
|
||||
|
||||
const exported = buildMembersFromDrafts(drafts, {
|
||||
inheritedProviderId: 'codex',
|
||||
});
|
||||
|
||||
expect(exported).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
model: 'gpt-5.4-mini',
|
||||
}),
|
||||
]);
|
||||
expect(exported[0]).not.toHaveProperty('effort');
|
||||
});
|
||||
|
||||
it('preserves legacy no-context effort export for callers without inherited provider', () => {
|
||||
const drafts = createMemberDraftsFromInputs(
|
||||
filterEditableMemberInputs([
|
||||
{
|
||||
name: 'alice',
|
||||
agentType: 'reviewer',
|
||||
effort: 'max',
|
||||
},
|
||||
] as any)
|
||||
);
|
||||
|
||||
expect(buildMembersFromDrafts(drafts)).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
effort: 'max',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses explicit teammate provider before inherited provider while sanitizing export', () => {
|
||||
const drafts = createMemberDraftsFromInputs(
|
||||
filterEditableMemberInputs([
|
||||
{
|
||||
name: 'alice',
|
||||
agentType: 'reviewer',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4-mini',
|
||||
effort: 'max',
|
||||
},
|
||||
] as any)
|
||||
);
|
||||
|
||||
const exported = buildMembersFromDrafts(drafts, {
|
||||
inheritedProviderId: 'anthropic',
|
||||
});
|
||||
|
||||
expect(exported).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4-mini',
|
||||
}),
|
||||
]);
|
||||
expect(exported[0]).not.toHaveProperty('effort');
|
||||
});
|
||||
|
||||
it('keeps OpenCode custom teammate models that are not inferred as another provider', () => {
|
||||
const drafts = createMemberDraftsFromInputs(
|
||||
filterEditableMemberInputs([
|
||||
{
|
||||
name: 'alice',
|
||||
agentType: 'reviewer',
|
||||
providerId: 'opencode',
|
||||
providerBackendId: 'opencode-cli',
|
||||
model: 'qwen3-coder',
|
||||
effort: 'medium',
|
||||
},
|
||||
] as any)
|
||||
);
|
||||
|
||||
const exported = buildMembersFromDrafts(drafts, {
|
||||
inheritedProviderId: 'anthropic',
|
||||
});
|
||||
|
||||
expect(exported).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
providerId: 'opencode',
|
||||
providerBackendId: 'opencode-cli',
|
||||
model: 'qwen3-coder',
|
||||
effort: 'medium',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves explicit codex models when exporting member inputs', () => {
|
||||
const drafts = createMemberDraftsFromInputs(
|
||||
filterEditableMemberInputs([
|
||||
|
|
@ -113,9 +318,10 @@ describe('members editor editable input filtering', () => {
|
|||
model: 'gpt-5.4-mini',
|
||||
effort: 'medium',
|
||||
},
|
||||
] satisfies Array<
|
||||
Pick<ResolvedTeamMember, 'name' | 'agentType' | 'providerId' | 'model' | 'effort'>
|
||||
>)
|
||||
] satisfies Pick<
|
||||
ResolvedTeamMember,
|
||||
'name' | 'agentType' | 'providerId' | 'model' | 'effort'
|
||||
>[])
|
||||
);
|
||||
|
||||
expect(buildMembersFromDrafts(drafts)).toEqual([
|
||||
|
|
@ -140,7 +346,7 @@ describe('members editor editable input filtering', () => {
|
|||
name: 'bob',
|
||||
agentType: 'reviewer',
|
||||
},
|
||||
] satisfies Array<Pick<ResolvedTeamMember, 'name' | 'agentType' | 'isolation'>>)
|
||||
] satisfies Pick<ResolvedTeamMember, 'name' | 'agentType' | 'isolation'>[])
|
||||
);
|
||||
|
||||
const exported = buildMembersFromDrafts(drafts);
|
||||
|
|
|
|||
|
|
@ -894,7 +894,7 @@ describe('RuntimeProviderManagementPanelView', () => {
|
|||
expect(host.textContent).toContain('Model probe passed');
|
||||
expect(host.textContent).toContain('Recommended');
|
||||
expect(host.textContent).toContain('Not recommended');
|
||||
expect(host.textContent).toContain('Unavailable in OpenCode');
|
||||
expect(host.textContent).toContain('Not verified in OpenCode');
|
||||
expect(host.textContent).toContain('Tested');
|
||||
expect(host.textContent).toContain('Tested with limits');
|
||||
expect(host.textContent).toContain('Recommended only');
|
||||
|
|
|
|||
|
|
@ -5020,8 +5020,233 @@ describe('teamSlice actions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('stages changed launchTeam params before the launch IPC resolves', async () => {
|
||||
const store = createSliceStore();
|
||||
const launchRequest = createDeferredPromise<{ runId: string }>();
|
||||
hoisted.launchTeam.mockImplementationOnce(() => launchRequest.promise);
|
||||
store.setState({
|
||||
launchParamsByTeam: {
|
||||
'my-team': {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const launchPromise = store.getState().launchTeam({
|
||||
teamName: 'my-team',
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
limitContext: false,
|
||||
});
|
||||
|
||||
launchRequest.resolve({ runId: 'run-2' });
|
||||
await launchPromise;
|
||||
|
||||
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
limitContext: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes stale providerBackendId before staging launchTeam params', async () => {
|
||||
const store = createSliceStore();
|
||||
const launchRequest = createDeferredPromise<{ runId: string }>();
|
||||
hoisted.launchTeam.mockImplementationOnce(() => launchRequest.promise);
|
||||
|
||||
const launchPromise = store.getState().launchTeam({
|
||||
teamName: 'my-team',
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'haiku',
|
||||
effort: 'low',
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
model: 'haiku',
|
||||
effort: 'low',
|
||||
limitContext: false,
|
||||
});
|
||||
|
||||
launchRequest.resolve({ runId: 'run-2' });
|
||||
await launchPromise;
|
||||
|
||||
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
model: 'haiku',
|
||||
effort: 'low',
|
||||
limitContext: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not stage a previous model when launchTeam changes provider without a model', async () => {
|
||||
const store = createSliceStore();
|
||||
const launchRequest = createDeferredPromise<{ runId: string }>();
|
||||
hoisted.launchTeam.mockImplementationOnce(() => launchRequest.promise);
|
||||
store.setState({
|
||||
launchParamsByTeam: {
|
||||
'my-team': {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
limitContext: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const launchPromise = store.getState().launchTeam({
|
||||
teamName: 'my-team',
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'anthropic',
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
model: 'default',
|
||||
effort: undefined,
|
||||
limitContext: false,
|
||||
});
|
||||
|
||||
launchRequest.resolve({ runId: 'run-2' });
|
||||
await launchPromise;
|
||||
});
|
||||
|
||||
it('stages Default when launchTeam keeps the provider but explicitly clears the model', async () => {
|
||||
const store = createSliceStore();
|
||||
const launchRequest = createDeferredPromise<{ runId: string }>();
|
||||
hoisted.launchTeam.mockImplementationOnce(() => launchRequest.promise);
|
||||
store.setState({
|
||||
launchParamsByTeam: {
|
||||
'my-team': {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const launchPromise = store.getState().launchTeam({
|
||||
teamName: 'my-team',
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: undefined,
|
||||
effort: 'low',
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.getState().launchParamsByTeam['my-team']).toEqual({
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'default',
|
||||
effort: 'low',
|
||||
limitContext: false,
|
||||
});
|
||||
|
||||
launchRequest.resolve({ runId: 'run-2' });
|
||||
await launchPromise;
|
||||
});
|
||||
|
||||
it('keeps previous launch params while a metadata-only relaunch request is pending', async () => {
|
||||
const store = createSliceStore();
|
||||
const previousParams = {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
};
|
||||
store.setState({
|
||||
launchParamsByTeam: {
|
||||
'my-team': previousParams,
|
||||
},
|
||||
});
|
||||
const launchRequest = createDeferredPromise<{ runId: string }>();
|
||||
hoisted.launchTeam.mockImplementationOnce(() => launchRequest.promise);
|
||||
|
||||
const launchPromise = store.getState().launchTeam({
|
||||
teamName: 'my-team',
|
||||
cwd: '/tmp/project',
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
expect(store.getState().launchParamsByTeam['my-team']).toEqual(previousParams);
|
||||
|
||||
launchRequest.resolve({ runId: 'run-2' });
|
||||
await launchPromise;
|
||||
|
||||
expect(store.getState().launchParamsByTeam['my-team']).toEqual(previousParams);
|
||||
});
|
||||
|
||||
it('rolls back staged launch params when launchTeam fails before provisioning starts', async () => {
|
||||
const store = createSliceStore();
|
||||
const previousParams = {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
};
|
||||
store.setState({
|
||||
launchParamsByTeam: {
|
||||
'my-team': previousParams,
|
||||
},
|
||||
});
|
||||
hoisted.launchTeam.mockRejectedValueOnce(new Error('launch failed'));
|
||||
|
||||
await expect(
|
||||
store.getState().launchTeam({
|
||||
teamName: 'my-team',
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
})
|
||||
).rejects.toThrow('launch failed');
|
||||
|
||||
expect(store.getState().launchParamsByTeam['my-team']).toEqual(previousParams);
|
||||
});
|
||||
|
||||
it('rolls back optimistic pending run on early createTeam failure', async () => {
|
||||
const store = createSliceStore();
|
||||
const previousParams = {
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
limitContext: false,
|
||||
};
|
||||
store.setState({
|
||||
launchParamsByTeam: {
|
||||
'my-team': previousParams,
|
||||
},
|
||||
});
|
||||
hoisted.createTeam.mockRejectedValue(new Error('create failed'));
|
||||
|
||||
await expect(
|
||||
|
|
@ -5029,12 +5254,16 @@ describe('teamSlice actions', () => {
|
|||
teamName: 'my-team',
|
||||
cwd: '/tmp/project',
|
||||
members: [],
|
||||
providerId: 'anthropic',
|
||||
model: 'sonnet',
|
||||
effort: 'low',
|
||||
})
|
||||
).rejects.toThrow('create failed');
|
||||
|
||||
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined();
|
||||
expect(Object.values(store.getState().provisioningRuns)).toHaveLength(0);
|
||||
expect(store.getState().provisioningErrorByTeam['my-team']).toBe('create failed');
|
||||
expect(store.getState().launchParamsByTeam['my-team']).toEqual(previousParams);
|
||||
});
|
||||
|
||||
it('hydrates visible non-selected graph tabs when config becomes ready', () => {
|
||||
|
|
|
|||
|
|
@ -156,6 +156,204 @@ describe('resolveMemberRuntimeSummary', () => {
|
|||
).toBe('5.4 Mini · Medium · Codex');
|
||||
});
|
||||
|
||||
it('uses lead launch params instead of stale persisted lead runtime fields', () => {
|
||||
const member = createMember({
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMemberRuntimeSummary(
|
||||
member,
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
model: 'haiku',
|
||||
effort: undefined,
|
||||
limitContext: false,
|
||||
},
|
||||
undefined
|
||||
)
|
||||
).toBe('Anthropic · Haiku 4.5');
|
||||
});
|
||||
|
||||
it('uses lead launch params instead of stale pending lead runtime evidence', () => {
|
||||
const member = createMember({
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMemberRuntimeSummary(
|
||||
member,
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
model: 'haiku',
|
||||
effort: undefined,
|
||||
limitContext: false,
|
||||
},
|
||||
createSpawnEntry({
|
||||
runtimeModel: 'gpt-5.5',
|
||||
runtimeAlive: true,
|
||||
}),
|
||||
{
|
||||
memberName: 'team-lead',
|
||||
alive: true,
|
||||
restartable: false,
|
||||
providerId: 'codex',
|
||||
runtimeModel: 'gpt-5.5',
|
||||
rssBytes: 300 * 1024 * 1024,
|
||||
updatedAt: '2026-04-18T18:00:00.000Z',
|
||||
}
|
||||
)
|
||||
).toBe('Anthropic · Haiku 4.5');
|
||||
});
|
||||
|
||||
it('uses pending lead launch params instead of stale same-provider runtime model evidence', () => {
|
||||
const member = createMember({
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMemberRuntimeSummary(
|
||||
member,
|
||||
{
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'high',
|
||||
limitContext: false,
|
||||
},
|
||||
createSpawnEntry({
|
||||
runtimeModel: 'gpt-5.5',
|
||||
runtimeAlive: true,
|
||||
}),
|
||||
{
|
||||
memberName: 'team-lead',
|
||||
alive: true,
|
||||
restartable: false,
|
||||
providerId: 'codex',
|
||||
runtimeModel: 'gpt-5.5',
|
||||
rssBytes: 300 * 1024 * 1024,
|
||||
updatedAt: '2026-04-18T18:00:00.000Z',
|
||||
}
|
||||
)
|
||||
).toBe('5.4 · High · Codex');
|
||||
});
|
||||
|
||||
it('uses pending lead default launch params instead of stale same-provider runtime model evidence', () => {
|
||||
const member = createMember({
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMemberRuntimeSummary(
|
||||
member,
|
||||
{
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: undefined,
|
||||
effort: 'low',
|
||||
limitContext: false,
|
||||
},
|
||||
createSpawnEntry({
|
||||
runtimeModel: 'gpt-5.5',
|
||||
runtimeAlive: true,
|
||||
}),
|
||||
{
|
||||
memberName: 'team-lead',
|
||||
alive: true,
|
||||
restartable: false,
|
||||
providerId: 'codex',
|
||||
runtimeModel: 'gpt-5.5',
|
||||
rssBytes: 300 * 1024 * 1024,
|
||||
updatedAt: '2026-04-18T18:00:00.000Z',
|
||||
}
|
||||
)
|
||||
).toBe('Codex · Default · Low');
|
||||
});
|
||||
|
||||
it('uses staged default launch params without duplicating the Codex backend label', () => {
|
||||
const member = createMember({
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMemberRuntimeSummary(
|
||||
member,
|
||||
{
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'default',
|
||||
effort: 'low',
|
||||
limitContext: false,
|
||||
},
|
||||
createSpawnEntry()
|
||||
)
|
||||
).toBe('Codex · Default · Low');
|
||||
});
|
||||
|
||||
it('uses pending launch params for stale primary teammate cards during provider switch', () => {
|
||||
const member = createMember({
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.5',
|
||||
effort: 'medium',
|
||||
laneKind: 'primary',
|
||||
laneOwnerProviderId: 'codex',
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveMemberRuntimeSummary(
|
||||
member,
|
||||
{
|
||||
providerId: 'anthropic',
|
||||
providerBackendId: undefined,
|
||||
model: 'haiku',
|
||||
effort: 'low',
|
||||
limitContext: false,
|
||||
},
|
||||
createSpawnEntry({
|
||||
runtimeModel: 'gpt-5.5',
|
||||
runtimeAlive: true,
|
||||
}),
|
||||
{
|
||||
memberName: 'alice',
|
||||
alive: true,
|
||||
restartable: false,
|
||||
providerId: 'codex',
|
||||
runtimeModel: 'gpt-5.5',
|
||||
rssBytes: 221 * 1024 * 1024,
|
||||
updatedAt: '2026-04-18T18:00:00.000Z',
|
||||
}
|
||||
)
|
||||
).toBe('Anthropic · Haiku 4.5 · Low');
|
||||
});
|
||||
|
||||
it('normalizes persisted legacy Codex lanes to the native runtime summary', () => {
|
||||
const member = createMember({ model: 'gpt-5.4-mini' });
|
||||
|
||||
|
|
|
|||
|
|
@ -165,579 +165,579 @@ describe('getOpenCodeTeamModelRecommendation', () => {
|
|||
it('marks OpenRouter routes missing from the OpenCode catalog as unavailable, not bad', () => {
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder-plus')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder-next')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder:free')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-next-80b-a3b-instruct:free')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/google/gemini-2.0-flash-lite-001')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-4.1-nano')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-4o-mini-2024-07-18')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-4o-mini-search-preview')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-plus')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-turbo')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-235b-a22b-2507')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/deepseek/deepseek-v3.2-exp')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-32b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-14b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-8b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwq-32b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/deepseek/deepseek-chat')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-nemo')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-small-24b-instruct-2501')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/cohere/command-r7b-12-2024')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/cohere/command-r-08-2024')).toMatchObject(
|
||||
{
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
}
|
||||
);
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/rekaai/reka-flash-3')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/rekaai/reka-edge')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/nvidia/nemotron-3-nano-30b-a3b')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-01')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/nvidia/llama-3.3-nemotron-super-49b-v1.5')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-max-thinking')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-large-2512')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/mistralai/devstral-medium')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/devstral-small')).toMatchObject(
|
||||
{
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
}
|
||||
);
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/mistralai/ministral-14b-2512')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/mistralai/ministral-8b-2512')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/mistralai/ministral-3b-2512')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-m2-her')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/xiaomi/mimo-v2.5')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/xiaomi/mimo-v2.5-pro')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4.20')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/google/gemini-3.1-flash-image-preview')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-5v-turbo')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4.20-multi-agent')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-small-creative')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.3-chat')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/mistralai/voxtral-small-24b-2507')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5-chat')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-2.5-72b-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/tngtech/deepseek-r1t2-chimera')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/google/gemini-2.5-pro-preview')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-saba')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-large-2411')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-30b-a3b-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/inclusionai/ling-2.6-1t:free')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/inclusionai/ling-2.6-flash:free')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/meta-llama/llama-3.1-8b-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-2.5-7b-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/amazon/nova-lite-v1')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-4-32b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/bytedance-seed/seed-1.6-flash')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/meta-llama/llama-4-scout')).toMatchObject(
|
||||
{
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
}
|
||||
);
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/meta-llama/llama-3.3-70b-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/bytedance-seed/seed-2.0-mini')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-32b-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/alibaba/tongyi-deepresearch-30b-a3b')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/arcee-ai/trinity-large-preview')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/amazon/nova-micro-v1')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/arcee-ai/trinity-mini')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3.5-9b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/essentialai/rnj-1-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/upstage/solar-pro-3')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/allenai/olmo-3.1-32b-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-plus-2025-07-28')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/tencent/hy3-preview:free')).toMatchObject(
|
||||
{
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
}
|
||||
);
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-8b-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/nex-agi/deepseek-v3.1-nex-n1')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/baidu/ernie-4.5-vl-28b-a3b')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/thedrummer/rocinante-12b')).toMatchObject(
|
||||
{
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
}
|
||||
);
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/meta-llama/llama-3.1-70b-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-plus-2025-07-28:thinking')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-4.6v')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/anthropic/claude-3-haiku')).toMatchObject(
|
||||
{
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
}
|
||||
);
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/bytedance-seed/seed-2.0-lite')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-235b-a22b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3.5-122b-a10b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/deepseek/deepseek-r1-0528')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/amazon/nova-2-lite-v1')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/o3-mini')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-large-2407')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/thedrummer/unslopnemo-12b')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-235b-a22b-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-8b-thinking')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/kwaipilot/kat-coder-pro-v2')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/o4-mini-high')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/o3-mini-high')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-4o')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/cohere/command-r-plus-08-2024')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-30b-a3b-thinking')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/sao10k/l3.1-euryale-70b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3.5-27b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/arcee-ai/virtuoso-large')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-3.5-turbo')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/bytedance-seed/seed-1.6')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/nvidia/llama-3.1-nemotron-70b-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-vl-max')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-235b-a22b-thinking')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-audio-mini')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/amazon/nova-pro-v1')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/relace/relace-search')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-max')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/mistralai/pixtral-large-2411')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mixtral-8x22b-instruct')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3.5-35b-a3b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-30b-a3b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(getOpenCodeTeamModelRecommendation('openrouter/baidu/ernie-4.5-21b-a3b')).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
expect(
|
||||
getOpenCodeTeamModelRecommendation('openrouter/nousresearch/hermes-4-70b')
|
||||
).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
for (const modelId of [
|
||||
'openrouter/openai/gpt-3.5-turbo-16k',
|
||||
|
|
@ -793,7 +793,7 @@ describe('getOpenCodeTeamModelRecommendation', () => {
|
|||
]) {
|
||||
expect(getOpenCodeTeamModelRecommendation(modelId)).toMatchObject({
|
||||
level: 'unavailable-in-opencode',
|
||||
label: 'Unavailable in OpenCode',
|
||||
label: 'Not verified in OpenCode',
|
||||
});
|
||||
}
|
||||
expect(isOpenCodeTeamModelRecommended('openrouter/qwen/qwen3-coder-plus')).toBe(false);
|
||||
|
|
|
|||
|
|
@ -85,6 +85,14 @@ describe('Shared Pricing Module', () => {
|
|||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should return 0 for synthetic model markers without warning', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
const cost = calculateMessageCost('<synthetic>', 1000, 500, 0, 0);
|
||||
expect(cost).toBe(0);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should include cache token costs', () => {
|
||||
const cost = calculateMessageCost('claude-4-sonnet-20250514', 1000, 500, 300, 200);
|
||||
expect(cost).toBeGreaterThan(0.0105);
|
||||
|
|
|
|||
30
test/shared/utils/providerBackend.test.ts
Normal file
30
test/shared/utils/providerBackend.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatProviderBackendLabel,
|
||||
migrateProviderBackendId,
|
||||
} from '../../../src/shared/utils/providerBackend';
|
||||
|
||||
describe('providerBackend utils', () => {
|
||||
it('does not let Codex backends leak into Anthropic selections', () => {
|
||||
expect(migrateProviderBackendId('anthropic', 'codex-native')).toBeUndefined();
|
||||
expect(formatProviderBackendLabel('anthropic', 'codex-native')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps Codex native defaults and legacy backend migration scoped to Codex', () => {
|
||||
expect(migrateProviderBackendId('codex', undefined)).toBe('codex-native');
|
||||
expect(migrateProviderBackendId('codex', 'api')).toBe('codex-native');
|
||||
expect(migrateProviderBackendId('codex', 'adapter')).toBe('codex-native');
|
||||
expect(migrateProviderBackendId('codex', 'opencode-cli')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps Gemini and OpenCode backend ids provider-specific', () => {
|
||||
expect(migrateProviderBackendId('gemini', 'api')).toBe('api');
|
||||
expect(migrateProviderBackendId('gemini', 'cli-sdk')).toBe('cli-sdk');
|
||||
expect(migrateProviderBackendId('gemini', 'codex-native')).toBeUndefined();
|
||||
expect(migrateProviderBackendId('opencode', 'opencode-cli')).toBe('opencode-cli');
|
||||
expect(migrateProviderBackendId('opencode', 'adapter')).toBe('adapter');
|
||||
expect(migrateProviderBackendId('opencode', 'codex-native')).toBeUndefined();
|
||||
expect(migrateProviderBackendId(undefined, 'codex-native')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue