feat(team): improve runtime provider workflows

This commit is contained in:
777genius 2026-05-17 19:11:26 +03:00
parent 4087060cca
commit 445932e45b
84 changed files with 7004 additions and 998 deletions

17
.mcp.json Normal file
View 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"
]
}
}
}

View file

@ -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. `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/`. The desktop app auto-discovers Claude Code projects from `~/.claude/`.
### Debug teammate runtimes ### Debug teammate runtimes
@ -309,21 +312,22 @@ local packaging.
### Scripts ### Scripts
| Command | Description | | Command | Description |
|---------|-------------| | ----------------------------- | ---------------------------------------------------------------------------- |
| `pnpm dev` | Desktop app development with hot reload | | `pnpm dev` | Desktop app development with hot reload |
| `pnpm build` | Production build | | `pnpm dev:mcp` | Desktop app development with hot reload and local CDP debugging on port 9222 |
| `pnpm typecheck` | TypeScript type checking | | `pnpm build` | Production build |
| `pnpm lint` | Lint (no auto-fix) | | `pnpm typecheck` | TypeScript type checking |
| `pnpm lint:fix` | Lint and auto-fix | | `pnpm lint` | Lint (no auto-fix) |
| `pnpm format` | Format code with Prettier | | `pnpm lint:fix` | Lint and auto-fix |
| `pnpm test` | Run all tests | | `pnpm format` | Format code with Prettier |
| `pnpm test:watch` | Watch mode | | `pnpm test` | Run all tests |
| `pnpm test:coverage` | Coverage report | | `pnpm test:watch` | Watch mode |
| `pnpm test:coverage:critical` | Critical path coverage | | `pnpm test:coverage` | Coverage report |
| `pnpm check` | Full quality gate (types + lint + test + build) | | `pnpm test:coverage:critical` | Critical path coverage |
| `pnpm fix` | Lint fix + format | | `pnpm check` | Full quality gate (types + lint + test + build) |
| `pnpm quality` | Full check + format check + knip | | `pnpm fix` | Lint fix + format |
| `pnpm quality` | Full check + format check + knip |
</details> </details>

View file

@ -305,6 +305,7 @@ function getStatusIcon(status: string): string {
<style scoped> <style scoped>
.comparison-section { .comparison-section {
position: relative; position: relative;
--comparison-sticky-header-offset: 76px;
} }
.comparison-section__header { .comparison-section__header {
@ -354,12 +355,13 @@ function getStatusIcon(status: string): string {
/* Header */ /* Header */
.comparison-table thead { .comparison-table thead {
position: sticky; position: static;
top: 64px;
z-index: 2;
} }
.comparison-table__th { .comparison-table__th {
position: sticky;
top: var(--comparison-sticky-header-offset);
z-index: 3;
padding: 16px 12px; padding: 16px 12px;
text-align: center; text-align: center;
font-weight: 600; font-weight: 600;
@ -382,7 +384,7 @@ function getStatusIcon(status: string): string {
.comparison-table__th--highlight { .comparison-table__th--highlight {
color: #00f0ff; color: #00f0ff;
background: rgba(0, 18, 20, 0.97); background: rgba(0, 18, 20, 0.97);
position: relative; z-index: 4;
} }
.comparison-table__th--highlight::after { .comparison-table__th--highlight::after {
@ -624,6 +626,10 @@ function getStatusIcon(status: string): string {
/* Responsive */ /* Responsive */
@media (max-width: 960px) { @media (max-width: 960px) {
.comparison-section {
--comparison-sticky-header-offset: 60px;
}
.comparison-table__wrap { .comparison-table__wrap {
overflow-x: auto; 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) { @media (max-width: 600px) {
.comparison-section__title { .comparison-section__title {
font-size: 1.6rem; font-size: 1.6rem;

View file

@ -19,6 +19,7 @@
"main": "dist-electron/main/index.cjs", "main": "dist-electron/main/index.cjs",
"scripts": { "scripts": {
"dev": "node ./scripts/dev-with-runtime.mjs", "dev": "node ./scripts/dev-with-runtime.mjs",
"dev:mcp": "node ./scripts/dev-with-runtime.mjs --remoteDebuggingPort 9222",
"dev:kill": "node bin/kill-dev.js", "dev:kill": "node bin/kill-dev.js",
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs", "opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
"opencode:prove-semantic-gauntlet": "node ./scripts/prove-opencode-semantic-gauntlet.mjs", "opencode:prove-semantic-gauntlet": "node ./scripts/prove-opencode-semantic-gauntlet.mjs",

View file

@ -2,16 +2,31 @@ diff --git a/dist/index.js b/dist/index.js
index c91ae9196280060974778cbb1164839d5610e7d0..a2dd82afe79d7d0a6640e983166b4b205686dae9 100644 index c91ae9196280060974778cbb1164839d5610e7d0..a2dd82afe79d7d0a6640e983166b4b205686dae9 100644
--- a/dist/index.js --- a/dist/index.js
+++ b/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 onMountAutoFocus = (0, import_react_use_callback_ref.useCallbackRef)(onMountAutoFocusProp);
const onUnmountAutoFocus = (0, import_react_use_callback_ref.useCallbackRef)(onUnmountAutoFocusProp); const onUnmountAutoFocus = (0, import_react_use_callback_ref.useCallbackRef)(onUnmountAutoFocusProp);
const lastFocusedElementRef = React.useRef(null); const lastFocusedElementRef = React.useRef(null);
- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node) => setContainer(node)); - const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node) => setContainer(node));
+ const containerRef = React.useRef(null); + const containerRef = React.useRef(null);
+ const containerCleanupGenerationRef = React.useRef(0);
+ const setContainerRef = React.useCallback((node) => { + const setContainerRef = React.useCallback((node) => {
+ if (containerRef.current === node) return; + const syncContainer = (nextContainer) => {
+ containerRef.current = node; + if (containerRef.current === nextContainer) return;
+ setContainer(node); + 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 composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, setContainerRef);
const focusScope = React.useRef({ const focusScope = React.useRef({
@ -21,16 +36,31 @@ diff --git a/dist/index.mjs b/dist/index.mjs
index e39d5c9105b3f8060d037bf5490843d20d1c859a..70781360acc81bff33c36b8ebd8d6b278df58450 100644 index e39d5c9105b3f8060d037bf5490843d20d1c859a..70781360acc81bff33c36b8ebd8d6b278df58450 100644
--- a/dist/index.mjs --- a/dist/index.mjs
+++ b/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 onMountAutoFocus = useCallbackRef(onMountAutoFocusProp);
const onUnmountAutoFocus = useCallbackRef(onUnmountAutoFocusProp); const onUnmountAutoFocus = useCallbackRef(onUnmountAutoFocusProp);
const lastFocusedElementRef = React.useRef(null); const lastFocusedElementRef = React.useRef(null);
- const composedRefs = useComposedRefs(forwardedRef, (node) => setContainer(node)); - const composedRefs = useComposedRefs(forwardedRef, (node) => setContainer(node));
+ const containerRef = React.useRef(null); + const containerRef = React.useRef(null);
+ const containerCleanupGenerationRef = React.useRef(0);
+ const setContainerRef = React.useCallback((node) => { + const setContainerRef = React.useCallback((node) => {
+ if (containerRef.current === node) return; + const syncContainer = (nextContainer) => {
+ containerRef.current = node; + if (containerRef.current === nextContainer) return;
+ setContainer(node); + 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 composedRefs = useComposedRefs(forwardedRef, setContainerRef);
const focusScope = React.useRef({ const focusScope = React.useRef({

View file

@ -10,7 +10,7 @@ overrides:
patchedDependencies: patchedDependencies:
'@radix-ui/react-focus-scope@1.1.7': '@radix-ui/react-focus-scope@1.1.7':
hash: 7804f20913d10756d0a4eb0a19936c4caffcfbd0142137ea30c880624940344d hash: cce5af533d09e336a548ebb15555869267a2545df720cfe228aa0a98da80e829
path: patches/@radix-ui__react-focus-scope@1.1.7.patch path: patches/@radix-ui__react-focus-scope@1.1.7.patch
'@radix-ui/react-presence@1.1.5': '@radix-ui/react-presence@1.1.5':
hash: afe90f800cfb3b1ce1a9c457772e2441a9202e1aa3f8658eb3b9613b3ba0ef7e 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-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-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-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-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-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) '@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: optionalDependencies:
'@types/react': 19.2.14 '@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: dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) '@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) '@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-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-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-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-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-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) '@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-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-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-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-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-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) '@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-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-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-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-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-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) '@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)

View file

@ -754,6 +754,35 @@
"supports_native_structured_output": true, "supports_native_structured_output": true,
"supports_minimal_reasoning_effort": 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": { "anthropic.claude-sonnet-4-20250514-v1:0": {
"cache_creation_input_token_cost": 0.00000375, "cache_creation_input_token_cost": 0.00000375,
"cache_read_input_token_cost": 3e-7, "cache_read_input_token_cost": 3e-7,

View file

@ -18,7 +18,9 @@ const defaultRuntimeCacheRoot = path.join(os.homedir(), '.agent-teams', 'runtime
const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim() const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim()
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim()) ? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
: defaultRuntimeCacheRoot; : 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 runtimeDisplayName = 'teams orchestrator';
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']); const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
@ -542,7 +544,7 @@ async function main() {
delete uiEnv.CLAUDE_CLI_PATH; delete uiEnv.CLAUDE_CLI_PATH;
const uiPackageManager = readPackageManagerCommand(uiRepoRoot); const uiPackageManager = readPackageManagerCommand(uiRepoRoot);
runOrExit(uiPackageManager, ['exec', 'electron-vite', 'dev'], { runOrExit(uiPackageManager, ['exec', 'electron-vite', 'dev', ...electronViteArgs], {
cwd: uiRepoRoot, cwd: uiRepoRoot,
env: uiEnv, env: uiEnv,
}); });

View file

@ -28,9 +28,9 @@ import type {
const LOG_PREVIEW_FALLBACK_WIDTH = 260; const LOG_PREVIEW_FALLBACK_WIDTH = 260;
const LOG_PREVIEW_FALLBACK_HEIGHT = 292; const LOG_PREVIEW_FALLBACK_HEIGHT = 292;
const NEW_LOG_HIGHLIGHT_MS = 1_000; const NEW_LOG_HIGHLIGHT_MS = 1_000;
const COMPACT_ROW_TITLE_LIMIT = 24; const COMPACT_ROW_TITLE_LIMIT = 28;
const COMPACT_ROW_TEXT_LIMIT = 76; const COMPACT_ROW_TEXT_LIMIT = 160;
const COMPACT_ROW_MIN_PREVIEW_LIMIT = 40; const COMPACT_ROW_MIN_PREVIEW_LIMIT = 96;
const INTERACTIVE_LOG_CONTROL_CLASS = 'pointer-events-auto'; const INTERACTIVE_LOG_CONTROL_CLASS = 'pointer-events-auto';
interface StableRectLike { interface StableRectLike {
@ -82,7 +82,7 @@ function formatRelativeTime(timestamp: string): string {
} }
function itemIcon(item: MemberLogPreviewItem): React.JSX.Element { 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(); const title = item.title.trim().toLowerCase();
if (item.tone === 'error') { if (item.tone === 'error') {
return <AlertCircle className={`${className} text-rose-300`} />; return <AlertCircle className={`${className} text-rose-300`} />;
@ -254,10 +254,10 @@ function renderLoadingSkeleton(): React.JSX.Element {
{[0, 1, 2].map((index) => ( {[0, 1, 2].map((index) => (
<span <span
key={index} 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="mt-0.5 inline-flex size-4 shrink-0 rounded bg-white/10" />
<span className="flex min-w-0 flex-1 flex-col gap-2 pt-0.5"> <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-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-full rounded bg-slate-400/15" />
<span className="h-2.5 w-2/3 rounded bg-slate-400/10" /> <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-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)]'; : '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 const iconClassName = isError
? 'float-left mr-2 mt-0 inline-flex size-5 shrink-0 items-center justify-center rounded bg-rose-500/10' ? 'inline-flex size-4 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'; : 'inline-flex size-4 shrink-0 items-center justify-center rounded bg-white/5';
const headerClassName = 'inline align-baseline'; const headerClassName = 'flex h-4 min-w-0 items-center gap-1.5';
const titleClassName = isError const titleClassName = isError
? 'align-baseline text-[11px] font-medium leading-5 text-rose-100' ? 'min-w-0 truncate text-[10.5px] font-medium leading-4 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-slate-200';
const timeClassName = isError const timeClassName = isError
? 'ml-1 align-baseline text-[9px] font-normal leading-5 text-rose-300/70' ? 'shrink-0 text-[9px] font-normal leading-4 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-slate-500';
const previewClassName = isError const previewClassName = isError
? 'ml-1 break-words align-baseline text-[10px] leading-5 text-rose-100/85' ? 'mt-1 line-clamp-2 min-w-0 break-words text-[10px] leading-[15px] 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-slate-300/85';
return ( return (
<button <button
key={item.id} key={item.id}
type="button" type="button"
className={[ 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, rowStateClassName,
].join(' ')} ].join(' ')}
title={titleText} title={titleText}
aria-label={titleText} aria-label={titleText}
onClick={() => openLogs(memberName)} onClick={() => openLogs(memberName)}
> >
<span className={iconClassName} aria-hidden="true">
{itemIcon(item)}
</span>
<span className={headerClassName}> <span className={headerClassName}>
<span className={iconClassName} aria-hidden="true">
{itemIcon(item)}
</span>
<span className={titleClassName}>{displayTitle}</span> <span className={titleClassName}>{displayTitle}</span>
{relativeTime ? <span className={timeClassName}>{relativeTime}</span> : null} {relativeTime ? <span className={timeClassName}>{relativeTime}</span> : null}
</span> </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-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"> <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-3 text-slate-500" /> <Wrench className="size-2.5 text-slate-500" />
Logs Logs
</div> </div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden"> <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">

View file

@ -8,7 +8,7 @@ import {
} from '@shared/utils/effortLevels'; } from '@shared/utils/effortLevels';
import { getErrorMessage } from '@shared/utils/errorHandling'; import { getErrorMessage } from '@shared/utils/errorHandling';
import { createLogger } from '@shared/utils/logger'; 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 { isTeamProviderId } from '@shared/utils/teamProvider';
import { constants as fsConstants } from 'fs'; import { constants as fsConstants } from 'fs';
import { access } from 'fs/promises'; import { access } from 'fs/promises';
@ -33,6 +33,9 @@ type CreateTeamBody = TeamCreateConfigRequest;
class HttpBadRequestError extends Error {} class HttpBadRequestError extends Error {}
class HttpFeatureUnavailableError 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 { function isMemberWorkSyncReportState(value: string): value is MemberWorkSyncReportState {
return value === 'still_working' || value === 'blocked' || value === 'caught_up'; return value === 'still_working' || value === 'blocked' || value === 'caught_up';
} }
@ -212,14 +215,30 @@ function parseProviderBackendId(
const rawProviderBackendId = assertOptionalString(value, 'providerBackendId'); const rawProviderBackendId = assertOptionalString(value, 'providerBackendId');
const providerBackendId = migrateProviderBackendId(providerId, rawProviderBackendId); const providerBackendId = migrateProviderBackendId(providerId, rawProviderBackendId);
if (rawProviderBackendId && !providerBackendId) { if (rawProviderBackendId && !providerBackendId) {
throw new HttpBadRequestError( throw new HttpBadRequestError(PROVIDER_BACKEND_ERROR);
'providerBackendId must be one of auto, adapter, api, cli-sdk, or codex-native'
);
} }
return providerBackendId; 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) { if (payloadMembers == null) {
return []; return [];
} }
@ -250,9 +269,12 @@ function parseCreateMembers(payloadMembers: unknown): TeamCreateConfigRequest['m
} }
const providerId = const providerId =
rawMember.providerId == null ? undefined : parseProviderId(rawMember.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 model = assertOptionalString(rawMember.model, 'member model');
const effort = assertOptionalEffort(rawMember.effort, providerId); const effort = assertOptionalEffort(rawMember.effort, providerId ?? defaultProviderId);
const fastMode = assertOptionalFastMode(rawMember.fastMode); const fastMode = assertOptionalFastMode(rawMember.fastMode);
return { return {
@ -273,9 +295,9 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {}; const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
const providerId = parseProviderId(payload.providerId); const providerId = parseProviderId(payload.providerId);
const prompt = assertOptionalString(payload.prompt, 'prompt'); 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 model = assertOptionalString(payload.model, 'model');
const effort = assertOptionalEffort(payload.effort, providerId); const effort = assertOptionalEffort(payload.effort, providerId ?? 'anthropic');
const fastMode = assertOptionalFastMode(payload.fastMode); const fastMode = assertOptionalFastMode(payload.fastMode);
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext'); const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions'); 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 payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
const teamName = assertProvisioningTeamName(payload.teamName); const teamName = assertProvisioningTeamName(payload.teamName);
const providerId = payload.providerId == null ? undefined : parseProviderId(payload.providerId); 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 displayName = assertOptionalString(payload.displayName, 'displayName');
const description = assertOptionalString(payload.description, 'description'); const description = assertOptionalString(payload.description, 'description');
const color = assertOptionalString(payload.color, 'color'); const color = assertOptionalString(payload.color, 'color');
const cwd = assertOptionalCwd(payload.cwd); const cwd = assertOptionalCwd(payload.cwd);
const prompt = assertOptionalString(payload.prompt, 'prompt'); const prompt = assertOptionalString(payload.prompt, 'prompt');
const model = assertOptionalString(payload.model, 'model'); 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 fastMode = assertOptionalFastMode(payload.fastMode);
const limitContext = assertOptionalBoolean(payload.limitContext, 'limitContext'); const limitContext = assertOptionalBoolean(payload.limitContext, 'limitContext');
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions'); const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
@ -336,7 +358,7 @@ function parseCreateTeamRequest(body: unknown): TeamCreateConfigRequest {
return { return {
teamName, teamName,
members: parseCreateMembers(payload.members), members: parseCreateMembers(payload.members, providerId ?? 'anthropic'),
...(displayName ? { displayName } : {}), ...(displayName ? { displayName } : {}),
...(description ? { description } : {}), ...(description ? { description } : {}),
...(color ? { color } : {}), ...(color ? { color } : {}),
@ -389,19 +411,29 @@ function parseDraftLaunchCreateRequest(
const providerId = Object.hasOwn(payload, 'providerId') const providerId = Object.hasOwn(payload, 'providerId')
? parseProviderId(payload.providerId) ? parseProviderId(payload.providerId)
: (savedRequest.providerId ?? 'anthropic'); : (savedRequest.providerId ?? 'anthropic');
const providerBackendId = parseProviderBackendId( const providerChangedFromSaved =
Object.hasOwn(payload, 'providerId') && providerId !== (savedRequest.providerId ?? 'anthropic');
const providerBackendId = parseLaunchProviderBackendId(
providerId, providerId,
Object.hasOwn(payload, 'providerBackendId') Object.hasOwn(payload, 'providerBackendId')
? payload.providerBackendId ? payload.providerBackendId
: savedRequest.providerBackendId : providerChangedFromSaved
? undefined
: savedRequest.providerBackendId
); );
const effort = assertOptionalEffort( const effort = assertOptionalEffort(
Object.hasOwn(payload, 'effort') ? payload.effort : savedRequest.effort, Object.hasOwn(payload, 'effort')
? payload.effort
: providerChangedFromSaved
? undefined
: savedRequest.effort,
providerId providerId
); );
const fastMode = Object.hasOwn(payload, 'fastMode') const fastMode = Object.hasOwn(payload, 'fastMode')
? assertOptionalFastMode(payload.fastMode) ? assertOptionalFastMode(payload.fastMode)
: savedRequest.fastMode; : providerChangedFromSaved
? undefined
: savedRequest.fastMode;
const extraCliArgs = Object.hasOwn(payload, 'extraCliArgs') const extraCliArgs = Object.hasOwn(payload, 'extraCliArgs')
? assertOptionalExtraCliArgs(payload.extraCliArgs) ? assertOptionalExtraCliArgs(payload.extraCliArgs)
: savedRequest.extraCliArgs; : savedRequest.extraCliArgs;
@ -419,13 +451,18 @@ function parseDraftLaunchCreateRequest(
prompt: pickOptionalString(payload, 'prompt', savedRequest.prompt, 'prompt'), prompt: pickOptionalString(payload, 'prompt', savedRequest.prompt, 'prompt'),
providerId, providerId,
...(providerBackendId ? { providerBackendId } : {}), ...(providerBackendId ? { providerBackendId } : {}),
model: pickOptionalString(payload, 'model', savedRequest.model, 'model'), model: pickOptionalString(
payload,
'model',
providerChangedFromSaved ? undefined : savedRequest.model,
'model'
),
...(effort ? { effort } : {}), ...(effort ? { effort } : {}),
...(fastMode ? { fastMode } : {}), ...(fastMode ? { fastMode } : {}),
limitContext: pickOptionalBoolean( limitContext: pickOptionalBoolean(
payload, payload,
'limitContext', 'limitContext',
savedRequest.limitContext, providerChangedFromSaved ? undefined : savedRequest.limitContext,
'limitContext' 'limitContext'
), ),
skipPermissions: pickOptionalBoolean( skipPermissions: pickOptionalBoolean(

View file

@ -1467,7 +1467,42 @@ function parseOptionalProviderBackendId(
return { return {
valid: false, 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)) { if (!Array.isArray(payload.members)) {
return { valid: false, error: 'members must be an array' }; return { valid: false, error: 'members must be an array' };
} }
const explicitProviderId = const providerValidation = parseOptionalTeamProviderId(payload.providerId);
payload.providerId === 'codex' if (!providerValidation.valid) {
? 'codex' return { valid: false, error: providerValidation.error };
: payload.providerId === 'gemini' }
? 'gemini' const providerId = providerValidation.value ?? 'anthropic';
: payload.providerId === 'anthropic'
? 'anthropic'
: undefined;
const providerId = explicitProviderId ?? 'anthropic';
const seenNames = new Set<string>(); const seenNames = new Set<string>();
const members: TeamCreateRequest['members'] = []; const members: TeamCreateRequest['members'] = [];
@ -1821,7 +1852,7 @@ async function validateProvisioningRequest(
} }
const providerBackendValidation = parseOptionalProviderBackendId( const providerBackendValidation = parseOptionalProviderBackendId(
(member as { providerBackendId?: unknown }).providerBackendId, (member as { providerBackendId?: unknown }).providerBackendId,
providerValidation.value providerValidation.value ?? providerId
); );
if (!providerBackendValidation.valid) { if (!providerBackendValidation.valid) {
return { valid: false, error: providerBackendValidation.error }; return { valid: false, error: providerBackendValidation.error };
@ -1867,7 +1898,7 @@ async function validateProvisioningRequest(
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') { if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
return { valid: false, error: 'prompt must be a string' }; return { valid: false, error: 'prompt must be a string' };
} }
const providerBackendValidation = parseOptionalProviderBackendId( const providerBackendValidation = parseOptionalLaunchProviderBackendId(
payload.providerBackendId, payload.providerBackendId,
providerId providerId
); );
@ -2076,16 +2107,13 @@ async function handleLaunchTeam(
if (payload.model !== undefined && typeof payload.model !== 'string') { if (payload.model !== undefined && typeof payload.model !== 'string') {
return { success: false, error: 'model must be a string' }; return { success: false, error: 'model must be a string' };
} }
const explicitProviderId = const providerValidation = parseOptionalTeamProviderId(payload.providerId);
payload.providerId === 'codex' if (!providerValidation.valid) {
? 'codex' return { success: false, error: providerValidation.error };
: payload.providerId === 'gemini' }
? 'gemini' const explicitProviderId = providerValidation.value;
: payload.providerId === 'anthropic'
? 'anthropic'
: undefined;
const providerId = explicitProviderId ?? 'anthropic'; const providerId = explicitProviderId ?? 'anthropic';
const providerBackendValidation = parseOptionalProviderBackendId( const providerBackendValidation = parseOptionalLaunchProviderBackendId(
payload.providerBackendId, payload.providerBackendId,
providerId providerId
); );
@ -2113,20 +2141,44 @@ async function handleLaunchTeam(
return { success: false, error: `Missing saved request for draft team: ${tn}` }; return { success: false, error: `Missing saved request for draft team: ${tn}` };
} }
const savedProviderId = savedRequest.providerId ?? 'anthropic';
const resolvedProviderId = explicitProviderId ?? savedRequest.providerId ?? providerId; const resolvedProviderId = explicitProviderId ?? savedRequest.providerId ?? providerId;
const providerChangedFromSaved =
explicitProviderId != null && explicitProviderId !== savedProviderId;
const effortValidation = parseOptionalTeamEffort( const effortValidation = parseOptionalTeamEffort(
Object.hasOwn(payload, 'effort') ? payload.effort : savedRequest.effort, Object.hasOwn(payload, 'effort')
? payload.effort
: providerChangedFromSaved
? undefined
: savedRequest.effort,
resolvedProviderId resolvedProviderId
); );
if (!effortValidation.valid) { if (!effortValidation.valid) {
return { success: false, error: effortValidation.error }; return { success: false, error: effortValidation.error };
} }
const fastModeValidation = parseOptionalTeamFastMode( const fastModeValidation = parseOptionalTeamFastMode(
Object.hasOwn(payload, 'fastMode') ? payload.fastMode : savedRequest.fastMode Object.hasOwn(payload, 'fastMode')
? payload.fastMode
: providerChangedFromSaved
? undefined
: savedRequest.fastMode
); );
if (!fastModeValidation.valid) { if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error }; 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 = { const createRequest: TeamCreateRequest = {
teamName: tn, teamName: tn,
@ -2143,14 +2195,10 @@ async function handleLaunchTeam(
resolvedProviderId, resolvedProviderId,
providerBackendValidation.value ?? savedRequest.providerBackendId providerBackendValidation.value ?? savedRequest.providerBackendId
), ),
model: model: draftModel,
typeof payload.model === 'string' ? payload.model.trim() || undefined : savedRequest.model,
effort: effortValidation.value, effort: effortValidation.value,
fastMode: fastModeValidation.value, fastMode: fastModeValidation.value,
limitContext: limitContext: draftLimitContext,
typeof payload.limitContext === 'boolean'
? payload.limitContext
: savedRequest.limitContext,
skipPermissions: skipPermissions:
typeof payload.skipPermissions === 'boolean' typeof payload.skipPermissions === 'boolean'
? payload.skipPermissions ? payload.skipPermissions
@ -2186,39 +2234,64 @@ async function handleLaunchTeam(
} }
const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null); const persistedMeta = await teamMetaStore.getMeta(tn).catch(() => null);
const launchProviderId = explicitProviderId ?? persistedMeta?.providerId ?? providerId; const persistedLaunchProviderId =
const rawLaunchProviderBackendId = persistedMeta?.launchIdentity?.providerId ?? persistedMeta?.providerId ?? 'anthropic';
payload.providerBackendId ?? const launchProviderId =
persistedMeta?.providerBackendId ?? explicitProviderId ??
persistedMeta?.launchIdentity?.providerBackendId ?? persistedMeta?.launchIdentity?.providerId ??
undefined; persistedMeta?.providerId ??
const launchProviderBackendValidation = parseOptionalProviderBackendId( 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, rawLaunchProviderBackendId,
launchProviderId launchProviderId
); );
if (!launchProviderBackendValidation.valid) { if (!launchProviderBackendValidation.valid) {
return { success: false, error: launchProviderBackendValidation.error }; return { success: false, error: launchProviderBackendValidation.error };
} }
const rawLaunchEffort = Object.hasOwn(payload, 'effort') const persistedLaunchEffort = providerChangedFromPersisted
? payload.effort ? undefined
: (persistedMeta?.effort ?? persistedMeta?.launchIdentity?.selectedEffort ?? undefined); : (persistedMeta?.launchIdentity?.selectedEffort ?? persistedMeta?.effort ?? undefined);
const rawLaunchEffort = Object.hasOwn(payload, 'effort') ? payload.effort : persistedLaunchEffort;
const effortValidation = parseOptionalTeamEffort(rawLaunchEffort, launchProviderId); const effortValidation = parseOptionalTeamEffort(rawLaunchEffort, launchProviderId);
if (!effortValidation.valid) { if (!effortValidation.valid) {
return { success: false, error: effortValidation.error }; return { success: false, error: effortValidation.error };
} }
const persistedLaunchFastMode = providerChangedFromPersisted
? undefined
: (persistedMeta?.launchIdentity?.selectedFastMode ?? persistedMeta?.fastMode ?? undefined);
const rawLaunchFastMode = Object.hasOwn(payload, 'fastMode') const rawLaunchFastMode = Object.hasOwn(payload, 'fastMode')
? payload.fastMode ? payload.fastMode
: (persistedMeta?.fastMode ?? persistedMeta?.launchIdentity?.selectedFastMode ?? undefined); : persistedLaunchFastMode;
const fastModeValidation = parseOptionalTeamFastMode(rawLaunchFastMode); const fastModeValidation = parseOptionalTeamFastMode(rawLaunchFastMode);
if (!fastModeValidation.valid) { if (!fastModeValidation.valid) {
return { success: false, error: fastModeValidation.error }; return { success: false, error: fastModeValidation.error };
} }
const rawLaunchModel = const persistedLaunchModel = providerChangedFromPersisted
typeof payload.model === 'string' && payload.model.trim().length > 0 ? 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() ? payload.model.trim()
: (persistedMeta?.model ?? persistedMeta?.launchIdentity?.selectedModel ?? undefined); : undefined
: persistedLaunchModel;
const launchLimitContext = const launchLimitContext =
typeof payload.limitContext === 'boolean' ? payload.limitContext : persistedMeta?.limitContext; typeof payload.limitContext === 'boolean'
? payload.limitContext
: providerChangedFromPersisted
? undefined
: persistedMeta?.limitContext;
return wrapTeamHandler('launch', async () => { return wrapTeamHandler('launch', async () => {
addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! }); addMainBreadcrumb('team', 'launch', { teamName: validatedTeamName.value! });
@ -3573,13 +3646,14 @@ async function handleCreateConfig(
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') { if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
return { success: false, error: 'prompt must be a string' }; return { success: false, error: 'prompt must be a string' };
} }
const providerValidation = parseOptionalTeamProviderId(payload.providerId); const teamProviderValidation = parseOptionalTeamProviderId(payload.providerId);
if (!providerValidation.valid) { if (!teamProviderValidation.valid) {
return { success: false, error: providerValidation.error }; return { success: false, error: teamProviderValidation.error };
} }
const providerBackendValidation = parseOptionalProviderBackendId( const effectiveTeamProviderId = teamProviderValidation.value ?? 'anthropic';
const providerBackendValidation = parseOptionalLaunchProviderBackendId(
payload.providerBackendId, payload.providerBackendId,
providerValidation.value effectiveTeamProviderId
); );
if (!providerBackendValidation.valid) { if (!providerBackendValidation.valid) {
return { success: false, error: providerBackendValidation.error }; return { success: false, error: providerBackendValidation.error };
@ -3587,7 +3661,7 @@ async function handleCreateConfig(
if (payload.model !== undefined && typeof payload.model !== 'string') { if (payload.model !== undefined && typeof payload.model !== 'string') {
return { success: false, error: 'model must be a 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) { if (!effortValidation.valid) {
return { success: false, error: effortValidation.error }; return { success: false, error: effortValidation.error };
} }
@ -3668,9 +3742,10 @@ async function handleCreateConfig(
if (!providerValidation.valid) { if (!providerValidation.valid) {
return { success: false, error: providerValidation.error }; return { success: false, error: providerValidation.error };
} }
const effectiveMemberProviderId = providerValidation.value ?? effectiveTeamProviderId;
const providerBackendValidation = parseOptionalProviderBackendId( const providerBackendValidation = parseOptionalProviderBackendId(
(member as { providerBackendId?: unknown }).providerBackendId, (member as { providerBackendId?: unknown }).providerBackendId,
providerValidation.value effectiveMemberProviderId
); );
if (!providerBackendValidation.valid) { if (!providerBackendValidation.valid) {
return { success: false, error: providerBackendValidation.error }; return { success: false, error: providerBackendValidation.error };
@ -3681,7 +3756,7 @@ async function handleCreateConfig(
} }
const effortValidation = parseOptionalMemberEffort( const effortValidation = parseOptionalMemberEffort(
(member as { effort?: unknown }).effort, (member as { effort?: unknown }).effort,
providerValidation.value effectiveMemberProviderId
); );
if (!effortValidation.valid) { if (!effortValidation.valid) {
return { success: false, error: effortValidation.error }; return { success: false, error: effortValidation.error };
@ -3714,7 +3789,7 @@ async function handleCreateConfig(
members, members,
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined, cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined, prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
providerId: providerValidation.value, providerId: teamProviderValidation.value,
providerBackendId: providerBackendValidation.value, providerBackendId: providerBackendValidation.value,
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined, model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
effort: effortValidation.value, effort: effortValidation.value,

View file

@ -12,7 +12,15 @@
import { countContentTokens } from '@main/utils/tokenizer'; 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. * Extract semantic steps from AI chunk responses.
@ -33,7 +41,7 @@ export function extractSemanticStepsFromAIChunk(chunk: AIChunk | EnhancedAIChunk
for (const msg of chunk.responses) { for (const msg of chunk.responses) {
if (msg.type === 'assistant') { if (msg.type === 'assistant') {
// Extract from content blocks // Extract from content blocks
const content = Array.isArray(msg.content) ? msg.content : []; const content = normalizeAssistantContent(msg.content);
for (const block of content) { for (const block of content) {
if (block.type === 'thinking' && block.thinking) { if (block.type === 'thinking' && block.thinking) {

View file

@ -4,6 +4,8 @@ import path from 'node:path';
import { resolveVerifiedAppManagedCodexRuntimeBinaryPath } from '@features/codex-runtime-installer/main'; import { resolveVerifiedAppManagedCodexRuntimeBinaryPath } from '@features/codex-runtime-installer/main';
import { execCli } from '@main/utils/childProcess'; import { execCli } from '@main/utils/childProcess';
import { buildEnrichedEnv } from '@main/utils/cliEnv';
import { buildMergedCliPath } from '@main/utils/cliPathMerge';
import { getCachedShellEnv } from '@main/utils/shellEnv'; import { getCachedShellEnv } from '@main/utils/shellEnv';
const CACHE_VERIFY_TTL_MS = 30_000; 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> { async function binaryCanLaunch(candidate: string): Promise<boolean> {
try { try {
await execCli(candidate, ['--version'], { await execCli(candidate, ['--version'], {
env: buildEnrichedEnv(candidate),
timeout: BINARY_LAUNCH_VERIFY_TIMEOUT_MS, timeout: BINARY_LAUNCH_VERIFY_TIMEOUT_MS,
windowsHide: true, windowsHide: true,
}); });
@ -69,7 +72,7 @@ function getPathEntries(): string[] {
const delimiter = process.platform === 'win32' ? ';' : path.delimiter; const delimiter = process.platform === 'win32' ? ';' : path.delimiter;
const shellEnv = getCachedShellEnv() ?? {}; const shellEnv = getCachedShellEnv() ?? {};
const seen = new Set<string>(); const seen = new Set<string>();
return [shellEnv.PATH, process.env.PATH] return [shellEnv.PATH, buildMergedCliPath(null), process.env.PATH]
.flatMap((pathValue) => (pathValue ?? '').split(delimiter)) .flatMap((pathValue) => (pathValue ?? '').split(delimiter))
.map((entry) => entry.trim()) .map((entry) => entry.trim())
.filter((entry) => { .filter((entry) => {
@ -193,6 +196,7 @@ export class CodexBinaryResolver {
try { try {
const result = await execCli(normalizedPath, ['--version'], { const result = await execCli(normalizedPath, ['--version'], {
env: buildEnrichedEnv(normalizedPath),
timeout: 3_000, timeout: 3_000,
}); });
const version = result.stdout.trim().split(/\s+/).filter(Boolean).at(-1) ?? null; const version = result.stdout.trim().split(/\s+/).filter(Boolean).at(-1) ?? null;

View file

@ -8,14 +8,27 @@ import type { PathLike } from 'node:fs';
const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>(); const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise<void>>();
const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn<() => Promise<string | null>>(); const resolveVerifiedAppManagedCodexRuntimeBinaryPathMock = vi.fn<() => Promise<string | null>>();
const execCliMock = const getCachedShellEnvMock = vi.fn<() => NodeJS.ProcessEnv | null>(() => null);
vi.fn< const buildEnrichedEnvMock = vi.fn(
( (binaryPath?: string | null): NodeJS.ProcessEnv => ({
binaryPath: string | null, PATH: `enriched:${binaryPath ?? ''}`,
args: string[], CODEX_RESOLVER_TEST_BINARY: binaryPath ?? '',
options?: { timeout?: number; windowsHide?: boolean } })
) => Promise<{ stdout: string; stderr: string }> );
>(); 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', () => ({ vi.mock('node:fs/promises', () => ({
access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode), access: (filePath: PathLike, mode?: number) => accessMock(filePath, mode),
@ -30,10 +43,26 @@ vi.mock('@main/utils/childProcess', () => ({
execCli: ( execCli: (
binaryPath: string | null, binaryPath: string | null,
args: string[], args: string[],
options?: { timeout?: number; windowsHide?: boolean } options?: {
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
timeout?: number;
windowsHide?: boolean;
}
) => execCliMock(binaryPath, args, options), ) => 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 originalPlatform = process.platform;
const originalPath = process.env.PATH; const originalPath = process.env.PATH;
const originalPathExt = process.env.PATHEXT; const originalPathExt = process.env.PATHEXT;
@ -54,6 +83,8 @@ describe('CodexBinaryResolver', () => {
setPlatform('win32'); setPlatform('win32');
process.env.PATHEXT = '.EXE;.CMD;.BAT;.COM'; process.env.PATHEXT = '.EXE;.CMD;.BAT;.COM';
delete process.env.CODEX_CLI_PATH; delete process.env.CODEX_CLI_PATH;
getCachedShellEnvMock.mockReturnValue(null);
buildMergedCliPathMock.mockImplementation(() => process.env.PATH ?? '');
resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null); resolveVerifiedAppManagedCodexRuntimeBinaryPathMock.mockResolvedValue(null);
execCliMock.mockResolvedValue({ stdout: 'codex-cli 0.130.0', stderr: '' }); execCliMock.mockResolvedValue({ stdout: 'codex-cli 0.130.0', stderr: '' });
}); });
@ -170,4 +201,93 @@ describe('CodexBinaryResolver', () => {
await expect(CodexBinaryResolver.resolve()).resolves.toBe(cmdShim); 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,
})
);
});
}); });

View file

@ -47,6 +47,7 @@ import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type { import type {
AgentChangeSet, AgentChangeSet,
ChangeStats, ChangeStats,
TaskChangeReviewability,
TaskChangeSetV2, TaskChangeSetV2,
TeamConfig, TeamConfig,
TeamTaskChangeSummariesResponse, 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_TASK_TIMEOUT_MS = 15_000;
const TEAM_TASK_CHANGE_SUMMARY_BATCH_TIMEOUT_MS = 30_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 файла + время протухания */ /** Кеш-запись: данные + mtime файла + время протухания */
interface CacheEntry { interface CacheEntry {
data: AgentChangeSet; data: AgentChangeSet;
@ -208,14 +242,18 @@ export class ChangeExtractorService {
const taskMeta = await this.readTaskMeta(teamName, taskId); const taskMeta = await this.readTaskMeta(teamName, taskId);
const effectiveOptions: TaskChangeEffectiveOptions = { const effectiveOptions: TaskChangeEffectiveOptions = {
owner: options?.owner ?? taskMeta?.owner, owner: options?.owner ?? taskMeta?.owner,
status: options?.status ?? taskMeta?.status, status: taskMeta?.status?.trim() || options?.status?.trim(),
intervals: options?.intervals ?? taskMeta?.intervals, intervals: options?.intervals ?? taskMeta?.intervals,
since: options?.since, since: options?.since,
}; };
const projectPath = await this.resolveProjectPath(teamName); const projectPath = await this.resolveProjectPath(teamName);
const cacheOptions: TaskChangeEffectiveOptions = {
...effectiveOptions,
status: getTaskMetaPreferredStatus(taskMeta, effectiveOptions),
};
const effectiveStateBucket = taskMeta const effectiveStateBucket = taskMeta
? getTaskChangeStateBucket({ ? getTaskChangeStateBucket({
status: effectiveOptions.status, status: cacheOptions.status,
reviewState: taskMeta.reviewState, reviewState: taskMeta.reviewState,
historyEvents: taskMeta.historyEvents, historyEvents: taskMeta.historyEvents,
kanbanColumn: taskMeta.kanbanColumn, kanbanColumn: taskMeta.kanbanColumn,
@ -279,7 +317,7 @@ export class ChangeExtractorService {
const cacheKey = this.buildTaskChangeSummaryCacheKey( const cacheKey = this.buildTaskChangeSummaryCacheKey(
teamName, teamName,
taskId, taskId,
effectiveOptions, cacheOptions,
effectiveStateBucket, effectiveStateBucket,
version version
); );
@ -306,7 +344,7 @@ export class ChangeExtractorService {
const persisted = await this.readPersistedTaskChangeSummary( const persisted = await this.readPersistedTaskChangeSummary(
teamName, teamName,
taskId, taskId,
effectiveOptions, cacheOptions,
effectiveStateBucket, effectiveStateBucket,
taskMeta taskMeta
); );
@ -333,7 +371,7 @@ export class ChangeExtractorService {
await this.persistTaskChangeSummary( await this.persistTaskChangeSummary(
teamName, teamName,
taskId, taskId,
effectiveOptions, cacheOptions,
effectiveStateBucket, effectiveStateBucket,
result, result,
version version
@ -1422,7 +1460,7 @@ export class ChangeExtractorService {
} }
const currentBucket = getTaskChangeStateBucket({ const currentBucket = getTaskChangeStateBucket({
status: taskMeta.status ?? effectiveOptions.status, status: getTaskMetaPreferredStatus(taskMeta, effectiveOptions),
reviewState: taskMeta.reviewState, reviewState: taskMeta.reviewState,
historyEvents: taskMeta.historyEvents, historyEvents: taskMeta.historyEvents,
kanbanColumn: taskMeta.kanbanColumn, kanbanColumn: taskMeta.kanbanColumn,
@ -1464,7 +1502,7 @@ export class ChangeExtractorService {
const currentTaskMeta = await this.readTaskMeta(teamName, taskId); const currentTaskMeta = await this.readTaskMeta(teamName, taskId);
if (!currentTaskMeta) return; if (!currentTaskMeta) return;
const currentBucket = getTaskChangeStateBucket({ const currentBucket = getTaskChangeStateBucket({
status: currentTaskMeta.status ?? effectiveOptions.status, status: getTaskMetaPreferredStatus(currentTaskMeta, effectiveOptions),
reviewState: currentTaskMeta.reviewState, reviewState: currentTaskMeta.reviewState,
historyEvents: currentTaskMeta.historyEvents, historyEvents: currentTaskMeta.historyEvents,
kanbanColumn: currentTaskMeta.kanbanColumn, kanbanColumn: currentTaskMeta.kanbanColumn,
@ -1546,7 +1584,15 @@ export class ChangeExtractorService {
const reviewability = classifyTaskChangeReviewability(result); const reviewability = classifyTaskChangeReviewability(result);
const resolvedPresence = resolveTaskChangePresenceFromResult(result); const resolvedPresence = resolveTaskChangePresenceFromResult(result);
if (!resolvedPresence) { 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); await this.taskChangePresenceRepository.deleteEntry?.(teamName, taskId);
} }
return; return;
@ -1555,7 +1601,7 @@ export class ChangeExtractorService {
const descriptor = buildTaskChangePresenceDescriptor({ const descriptor = buildTaskChangePresenceDescriptor({
createdAt: taskMeta.createdAt, createdAt: taskMeta.createdAt,
owner: effectiveOptions.owner ?? taskMeta.owner, owner: effectiveOptions.owner ?? taskMeta.owner,
status: effectiveOptions.status ?? taskMeta.status, status: getTaskMetaPreferredStatus(taskMeta, effectiveOptions),
intervals: effectiveOptions.intervals ?? taskMeta.intervals, intervals: effectiveOptions.intervals ?? taskMeta.intervals,
since: effectiveOptions.since, since: effectiveOptions.since,
reviewState: taskMeta.reviewState, reviewState: taskMeta.reviewState,

View file

@ -1,4 +1,5 @@
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { getTaskChangeStateBucket } from '@shared/utils/taskChangeState';
import { createReadStream } from 'fs'; import { createReadStream } from 'fs';
import { stat } from 'fs/promises'; import { stat } from 'fs/promises';
import * as readline from 'readline'; import * as readline from 'readline';
@ -20,6 +21,7 @@ import type {
} from '@shared/types'; } from '@shared/types';
const logger = createLogger('Service:TaskChangeComputer'); const logger = createLogger('Service:TaskChangeComputer');
const NO_LOG_FILES_FOUND_WARNING = 'No log files found for this task.';
interface ParsedSnippetsCacheEntry { interface ParsedSnippetsCacheEntry {
data: ParsedSnippetRecord[]; data: ParsedSnippetRecord[];
@ -51,6 +53,17 @@ interface ParsedJsonlEntry {
lineNumber: number; 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 { export class TaskChangeComputer {
private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>(); private parsedSnippetsCache = new Map<string, ParsedSnippetsCacheEntry>();
private parsedSnippetsInFlight = new Map<string, Promise<ParsedSnippetsResult>>(); private parsedSnippetsInFlight = new Map<string, Promise<ParsedSnippetsResult>>();
@ -102,7 +115,7 @@ export class TaskChangeComputer {
effectiveOptions effectiveOptions
); );
if (logRefs.length === 0) { if (logRefs.length === 0) {
return this.emptyTaskChangeSet(teamName, taskId); return this.emptyTaskChangeSet(input);
} }
const allScopes: TaskChangeScope[] = []; 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 { return {
teamName, teamName,
taskId, taskId,
@ -462,7 +476,7 @@ export class TaskChangeComputer {
filePaths: [], filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' }, 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] : [],
}; };
} }

View file

@ -557,6 +557,12 @@ export class TeamDataService {
} }
const launchIdentity = teamMeta?.launchIdentity; 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 leadName = 'team-lead';
const ownedTasks = tasks.filter((task) => task.owner === leadName); const ownedTasks = tasks.filter((task) => task.owner === leadName);
const currentTask = selectCurrentActiveTeamTask(ownedTasks); const currentTask = selectCurrentActiveTeamTask(ownedTasks);
@ -572,10 +578,7 @@ export class TeamDataService {
workflow: undefined, workflow: undefined,
isolation: undefined, isolation: undefined,
providerId: launchIdentity?.providerId ?? teamMeta?.providerId, providerId: launchIdentity?.providerId ?? teamMeta?.providerId,
providerBackendId: providerBackendId,
launchIdentity?.providerBackendId ??
migrateProviderBackendId(teamMeta?.providerId, teamMeta?.providerBackendId) ??
undefined,
model: model:
launchIdentity?.resolvedLaunchModel ?? launchIdentity?.selectedModel ?? teamMeta?.model, launchIdentity?.resolvedLaunchModel ?? launchIdentity?.selectedModel ?? teamMeta?.model,
effort: effort:
@ -1382,6 +1385,14 @@ export class TeamDataService {
: tasksWithKanbanBase; : tasksWithKanbanBase;
mark('changePresence'); 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( const members = this.memberResolver.resolveMembers(
config, config,
metaMembers, metaMembers,
@ -1389,11 +1400,8 @@ export class TeamDataService {
tasksWithKanban, tasksWithKanban,
{ {
launchSnapshot, launchSnapshot,
leadProviderId: teamMeta?.launchIdentity?.providerId ?? teamMeta?.providerId, leadProviderId: launchIdentity?.providerId ?? teamMeta?.providerId,
leadProviderBackendId: leadProviderBackendId,
teamMeta?.launchIdentity?.providerBackendId ??
migrateProviderBackendId(teamMeta?.providerId, teamMeta?.providerBackendId) ??
undefined,
leadFastMode: teamMeta?.launchIdentity?.selectedFastMode ?? teamMeta?.fastMode ?? undefined, leadFastMode: teamMeta?.launchIdentity?.selectedFastMode ?? teamMeta?.fastMode ?? undefined,
leadResolvedFastMode: leadResolvedFastMode:
typeof teamMeta?.launchIdentity?.resolvedFastMode === 'boolean' typeof teamMeta?.launchIdentity?.resolvedFastMode === 'boolean'

View file

@ -300,13 +300,15 @@ export class TeamMemberResolver {
providerId: effectiveProviderId, providerId: effectiveProviderId,
}, },
}); });
const providerBackendId = const providerBackendId = migrateProviderBackendId(
effectiveProviderId,
launchMember?.providerBackendId ?? launchMember?.providerBackendId ??
configMember?.providerBackendId ?? configMember?.providerBackendId ??
metaMember?.providerBackendId ?? metaMember?.providerBackendId ??
(effectiveProviderId === options?.leadProviderId (effectiveProviderId === options?.leadProviderId
? (options?.leadProviderBackendId ?? undefined) ? (options?.leadProviderBackendId ?? undefined)
: undefined); : undefined)
);
const agentId = configMember?.agentId ?? metaMember?.agentId; const agentId = configMember?.agentId ?? metaMember?.agentId;
members.push({ members.push({
name, name,

View file

@ -3668,17 +3668,14 @@ function buildEffectiveTeamMemberSpec(
const memberProviderId = normalizeTeamMemberProviderId(member.providerId); const memberProviderId = normalizeTeamMemberProviderId(member.providerId);
const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId); const defaultProviderId = normalizeTeamMemberProviderId(defaults.providerId);
const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic'; const effectiveProviderId = memberProviderId ?? defaultProviderId ?? 'anthropic';
const explicitMemberModel = getExplicitLaunchModelSelection(member.model);
const inheritsDefaultRuntime = memberProviderId == null || memberProviderId === defaultProviderId;
const model = const model =
getExplicitLaunchModelSelection(member.model) || explicitMemberModel ||
(memberProviderId == null || memberProviderId === defaultProviderId (inheritsDefaultRuntime ? getExplicitLaunchModelSelection(defaults.model) : undefined) ||
? getExplicitLaunchModelSelection(defaults.model)
: undefined) ||
undefined; undefined;
const effort = const effort =
member.effort ?? member.effort ?? (inheritsDefaultRuntime && !explicitMemberModel ? defaults.effort : undefined);
(memberProviderId == null || memberProviderId === defaultProviderId
? defaults.effort
: undefined);
return { return {
...member, ...member,
@ -15173,6 +15170,16 @@ export class TeamProvisioningService {
return fallback; 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>(); const candidateMembers = new Map<string, TeamMember>();
for (const member of configuredMembers) { for (const member of configuredMembers) {
const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; const memberName = typeof member?.name === 'string' ? member.name.trim() : '';
@ -15201,6 +15208,11 @@ export class TeamProvisioningService {
fastMode: launchMember?.selectedFastMode, 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()) { for (const member of candidateMembers.values()) {
const memberName = typeof member?.name === 'string' ? member.name.trim() : ''; const memberName = typeof member?.name === 'string' ? member.name.trim() : '';
@ -15234,22 +15246,52 @@ export class TeamProvisioningService {
const liveRuntimeMember = getLiveRuntimeMember(memberName); const liveRuntimeMember = getLiveRuntimeMember(memberName);
const spawnStatusMember = getSpawnStatusMember(memberName); const spawnStatusMember = getSpawnStatusMember(memberName);
const launchMember = launchSnapshot?.members[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 = const backendType =
liveRuntimeMember?.backendType ?? liveRuntimeMember?.backendType ??
normalizeTeamAgentRuntimeBackendType(persistedRuntimeMember?.backendType, false); normalizeTeamAgentRuntimeBackendType(persistedRuntimeMember?.backendType, false);
const runtimeModel = const runtimeModel =
liveRuntimeMember?.model ?? (canUseLiveRuntimeModel ? liveRuntimeModel : undefined) ??
activeRunModel ??
launchMember?.model?.trim() ?? launchMember?.model?.trim() ??
member.model?.trim() ?? member.model?.trim() ??
undefined; undefined;
const memberProviderId = const memberProviderId =
launchMember?.providerId ?? activeRunProviderId ??
normalizeOptionalTeamProviderId(launchMember?.providerId) ??
normalizeOptionalTeamProviderId(member.providerId) ?? normalizeOptionalTeamProviderId(member.providerId) ??
inferTeamProviderIdFromModel(runtimeModel) ?? inferTeamProviderIdFromModel(runtimeModel) ??
inferTeamProviderIdFromModel(launchMember?.model) ?? inferTeamProviderIdFromModel(launchMember?.model) ??
inferTeamProviderIdFromModel(member.model); inferTeamProviderIdFromModel(member.model);
const memberProviderBackendId = migrateProviderBackendId(
memberProviderId,
activeRunMember?.providerBackendId ??
launchMember?.providerBackendId ??
member.providerBackendId
);
const isOpenCodeMember = memberProviderId === 'opencode'; 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 = const runtimeCwd =
liveRuntimeMember?.cwd ?? liveRuntimeMember?.cwd ??
(configuredCwd || (isOpenCodeMember ? currentRuntimeAdapterRun?.cwd : undefined)); (configuredCwd || (isOpenCodeMember ? currentRuntimeAdapterRun?.cwd : undefined));
@ -15319,9 +15361,7 @@ export class TeamProvisioningService {
restartable, restartable,
...(backendType ? { backendType } : {}), ...(backendType ? { backendType } : {}),
...(memberProviderId ? { providerId: memberProviderId } : {}), ...(memberProviderId ? { providerId: memberProviderId } : {}),
...(launchMember?.providerBackendId ...(memberProviderBackendId ? { providerBackendId: memberProviderBackendId } : {}),
? { providerBackendId: launchMember.providerBackendId }
: {}),
...(launchMember?.laneId ? { laneId: launchMember.laneId } : {}), ...(launchMember?.laneId ? { laneId: launchMember.laneId } : {}),
...(launchMember?.laneKind ? { laneKind: launchMember.laneKind } : {}), ...(launchMember?.laneKind ? { laneKind: launchMember.laneKind } : {}),
...(displayPid ? { pid: displayPid } : {}), ...(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 = { const snapshot: TeamAgentRuntimeSnapshot = {
teamName, teamName,
updatedAt, updatedAt,
runId: run?.runId ?? runId, runId: run?.runId ?? runId,
providerBackendId: migrateProviderBackendId( providerBackendId: migrateProviderBackendId(snapshotProviderId, snapshotProviderBackendId),
run?.request.providerId ?? persistedTeamMeta?.providerId, fastMode:
run?.request.providerBackendId ?? persistedTeamMeta?.providerBackendId run?.request.fastMode ??
), persistedLaunchIdentity?.selectedFastMode ??
fastMode: run?.request.fastMode ?? persistedTeamMeta?.fastMode, persistedTeamMeta?.fastMode,
members: snapshotMembers, members: snapshotMembers,
}; };
@ -25017,18 +25067,24 @@ export class TeamProvisioningService {
run: ProvisioningRun | null, run: ProvisioningRun | null,
memberName: string memberName: string
): string | undefined { ): 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) { if (!run) {
return undefined; return undefined;
} }
for (const member of run.effectiveMembers ?? []) { for (const member of [...(run.allEffectiveMembers ?? []), ...(run.effectiveMembers ?? [])]) {
const candidateName = member.name?.trim() ?? ''; const candidateName = member.name?.trim() ?? '';
if (!candidateName || !matchesTeamMemberIdentity(candidateName, memberName)) { if (!candidateName || !matchesTeamMemberIdentity(candidateName, memberName)) {
continue; continue;
} }
const model = member.model?.trim(); return member;
if (model) {
return model;
}
} }
return undefined; return undefined;
} }
@ -25204,8 +25260,8 @@ export class TeamProvisioningService {
continue; continue;
} }
const runtimeModel = const runtimeModel =
this.findConfiguredMemberModel(configuredMembers, memberName) ??
this.findEffectiveRunMemberModel(run, memberName) ?? this.findEffectiveRunMemberModel(run, memberName) ??
this.findConfiguredMemberModel(configuredMembers, memberName) ??
this.findMetaMemberModel(metaMembers, memberName); this.findMetaMemberModel(metaMembers, memberName);
upsertMetadata(memberName, { upsertMetadata(memberName, {
backendType: normalizeTeamAgentRuntimeBackendType(member.backendType, false), backendType: normalizeTeamAgentRuntimeBackendType(member.backendType, false),
@ -25248,8 +25304,8 @@ export class TeamProvisioningService {
? configuredRuntimeMember.backendType ? configuredRuntimeMember.backendType
: undefined; : undefined;
const runtimeModel = const runtimeModel =
member.model?.trim() ||
this.findEffectiveRunMemberModel(run, memberName) || this.findEffectiveRunMemberModel(run, memberName) ||
member.model?.trim() ||
this.findMetaMemberModel(metaMembers, memberName); this.findMetaMemberModel(metaMembers, memberName);
upsertMetadata(memberName, { upsertMetadata(memberName, {
...(runtimeModel ? { model: runtimeModel } : {}), ...(runtimeModel ? { model: runtimeModel } : {}),
@ -25277,9 +25333,9 @@ export class TeamProvisioningService {
continue; continue;
} }
const runtimeModel = const runtimeModel =
this.findEffectiveRunMemberModel(run, memberName) ||
member.model?.trim() || member.model?.trim() ||
this.findConfiguredMemberModel(configuredMembers, memberName) || this.findConfiguredMemberModel(configuredMembers, memberName);
this.findEffectiveRunMemberModel(run, memberName);
upsertMetadata(memberName, { upsertMetadata(memberName, {
...(runtimeModel ? { model: runtimeModel } : {}), ...(runtimeModel ? { model: runtimeModel } : {}),
...(normalizeOptionalTeamProviderId(member.providerId) ...(normalizeOptionalTeamProviderId(member.providerId)
@ -25297,8 +25353,10 @@ export class TeamProvisioningService {
if (!memberName || isLeadMember(member) || memberName.toLowerCase() === 'user') { if (!memberName || isLeadMember(member) || memberName.toLowerCase() === 'user') {
continue; continue;
} }
const providerId = normalizeOptionalTeamProviderId(member.providerId);
upsertMetadata(memberName, { upsertMetadata(memberName, {
...(member.model?.trim() ? { model: member.model.trim() } : {}), ...(member.model?.trim() ? { model: member.model.trim() } : {}),
...(providerId ? { providerId } : {}),
}); });
} }
@ -25342,13 +25400,19 @@ export class TeamProvisioningService {
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) { if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
continue; 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]; const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
upsertMetadata(memberName, { upsertMetadata(memberName, {
backendType: backendType:
persistedMember.providerId === 'opencode' effectiveProviderId === 'opencode'
? 'process' ? 'process'
: metadataByMember.get(memberName)?.backendType, : metadataByMember.get(memberName)?.backendType,
providerId: persistedMember.providerId, providerId: effectiveProviderId,
alive: false, alive: false,
livenessKind: currentRuntimeAdapterEvidence?.livenessKind ?? persistedMember.livenessKind, livenessKind: currentRuntimeAdapterEvidence?.livenessKind ?? persistedMember.livenessKind,
pidSource: currentRuntimeAdapterEvidence?.pidSource ?? persistedMember.pidSource, pidSource: currentRuntimeAdapterEvidence?.pidSource ?? persistedMember.pidSource,
@ -25359,7 +25423,11 @@ export class TeamProvisioningService {
persistedMember.runtimeLastSeenAt ?? persistedMember.runtimeLastSeenAt ??
persistedMember.lastHeartbeatAt ?? persistedMember.lastHeartbeatAt ??
persistedMember.lastRuntimeAliveAt, persistedMember.lastRuntimeAliveAt,
...(persistedMember.model?.trim() ? { model: persistedMember.model.trim() } : {}), ...(activeRunModel
? { model: activeRunModel }
: persistedMember.model?.trim()
? { model: persistedMember.model.trim() }
: {}),
...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' && ...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' &&
currentRuntimeAdapterEvidence.runtimePid > 0 currentRuntimeAdapterEvidence.runtimePid > 0
? { metricsPid: currentRuntimeAdapterEvidence.runtimePid } ? { metricsPid: currentRuntimeAdapterEvidence.runtimePid }
@ -27758,7 +27826,10 @@ export class TeamProvisioningService {
} }
const teamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null); 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') { if (!leadProviderId || leadProviderId === 'opencode') {
return null; return null;
} }
@ -27793,9 +27864,13 @@ export class TeamProvisioningService {
providerBackendId: providerBackendId:
migrateProviderBackendId( migrateProviderBackendId(
leadProviderId, leadProviderId,
teamMeta?.providerBackendId ?? membersMeta?.providerBackendId leadLaunchIdentity
? (leadLaunchIdentity.providerBackendId ??
teamMeta?.providerBackendId ??
membersMeta?.providerBackendId)
: (teamMeta?.providerBackendId ?? membersMeta?.providerBackendId)
) ?? null, ) ?? null,
selectedFastMode: teamMeta?.fastMode, selectedFastMode: leadLaunchIdentity?.selectedFastMode ?? teamMeta?.fastMode,
resolvedFastMode: resolvedFastMode:
typeof teamMeta?.launchIdentity?.resolvedFastMode === 'boolean' typeof teamMeta?.launchIdentity?.resolvedFastMode === 'boolean'
? teamMeta.launchIdentity.resolvedFastMode ? teamMeta.launchIdentity.resolvedFastMode

View file

@ -100,6 +100,13 @@ const SECRET_FLAG_PATTERN =
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
const BEARER_TOKEN_PATTERN = /\bBearer\s+\S+/gi; const BEARER_TOKEN_PATTERN = /\bBearer\s+\S+/gi;
const SECRET_KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}\b/g; 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( function resolveOpenCodeRuntimeSettlementMode(
input: Pick<OpenCodeTeamRuntimeMessageInput, 'messageKind'> input: Pick<OpenCodeTeamRuntimeMessageInput, 'messageKind'>
@ -184,7 +191,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
]); ]);
} }
const runtimeSnapshot = skipReadinessPreflight let runtimeSnapshot = skipReadinessPreflight
? null ? null
: (this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null); : (this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null);
if ( if (
@ -197,23 +204,56 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
]); ]);
} }
this.lastProjectPathByTeamName.set(input.teamName, input.cwd); this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
const data = await this.bridge.launchOpenCodeTeam({ const buildLaunchCommand = (
snapshot: OpenCodeBridgeRuntimeSnapshot | null,
model: string
): OpenCodeLaunchTeamCommandBody => ({
runId: input.runId, runId: input.runId,
laneId: input.laneId?.trim() || 'primary', laneId: input.laneId?.trim() || 'primary',
teamId: input.teamName, teamId: input.teamName,
teamName: input.teamName, teamName: input.teamName,
projectPath: input.cwd, projectPath: input.cwd,
selectedModel, selectedModel: model,
members: input.expectedMembers.map((member) => ({ members: input.expectedMembers.map((member) => ({
name: member.name, name: member.name,
role: member.role?.trim() || member.workflow?.trim() || 'teammate', role: member.role?.trim() || member.workflow?.trim() || 'teammate',
prompt: buildMemberBootstrapPrompt(input, member), prompt: buildMemberBootstrapPrompt(input, member),
})), })),
leadPrompt: input.prompt?.trim() ?? '', leadPrompt: input.prompt?.trim() ?? '',
expectedCapabilitySnapshotId: runtimeSnapshot?.capabilitySnapshotId ?? null, expectedCapabilitySnapshotId: snapshot?.capabilitySnapshotId ?? null,
manifestHighWatermark: 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); return mapOpenCodeLaunchDataToRuntimeResult(input, data, launchWarnings);
} }
@ -1053,6 +1093,30 @@ function formatOpenCodeBridgeDiagnostic(diagnostic: {
return `${diagnostic.severity}:${diagnostic.code}: ${diagnostic.message}`; 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 { function isOpenCodeLaunchTimingDiagnostic(diagnostic: string): boolean {
return ( return (
diagnostic.startsWith('info:opencode_launch_member_timing:') || diagnostic.startsWith('info:opencode_launch_member_timing:') ||

View file

@ -310,7 +310,7 @@ export function isParsedInternalUserMessage(msg: ParsedMessage): boolean {
* - Interruption messages: [Request interrupted by user...] * - Interruption messages: [Request interrupted by user...]
* *
* Filtered assistant messages: * 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 { export function isParsedHardNoiseMessage(msg: ParsedMessage): boolean {
// Filter structural metadata types - these should never be displayed // 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 === 'file-history-snapshot') return true;
if (msg.type === 'queue-operation') return true; if (msg.type === 'queue-operation') return true;
// Filter synthetic assistant messages (system-generated placeholders) // Filter empty synthetic assistant placeholders, but keep Codex-native synthetic
if (msg.type === 'assistant' && msg.model === '<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; return true;
} }
@ -368,6 +373,29 @@ export function isParsedHardNoiseMessage(msg: ParsedMessage): boolean {
return false; 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. * Detect compact summary messages.
* These are markers indicating conversation was compacted. * These are markers indicating conversation was compacted.

View file

@ -191,6 +191,7 @@ interface RawMember {
name?: unknown; name?: unknown;
agentType?: unknown; agentType?: unknown;
role?: unknown; role?: unknown;
cwd?: unknown;
color?: unknown; color?: unknown;
providerId?: unknown; providerId?: unknown;
provider?: unknown; provider?: unknown;
@ -666,6 +667,59 @@ function isRawMember(v: unknown): v is RawMember {
return !!v && typeof v === 'object'; 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( function mergeMember(
m: RawMember, m: RawMember,
memberMap: Map<string, { name: string; role?: string; color?: string }>, 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 typeof config.color === 'string' && config.color.trim().length > 0
? config.color ? config.color
: undefined; : undefined;
projectPath = projectPath = resolveProjectPathFromConfig(config);
typeof config.projectPath === 'string' && config.projectPath.trim().length > 0
? config.projectPath
: undefined;
leadSessionId = leadSessionId =
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId ? config.leadSessionId

View file

@ -741,6 +741,7 @@ export const GlobalTaskList = memo(function GlobalTaskList({
<SidebarTaskItem <SidebarTaskItem
task={task} task={task}
hideTeamName hideTeamName
hideProjectName
renamingKey={renamingTaskKey} renamingKey={renamingTaskKey}
onRenameComplete={handleRenameComplete} onRenameComplete={handleRenameComplete}
onRenameCancel={handleRenameCancel} onRenameCancel={handleRenameCancel}

View file

@ -4,6 +4,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
import { cn } from '@renderer/lib/utils';
import { clearTaskManualUnread } from '@renderer/services/commentReadStorage'; import { clearTaskManualUnread } from '@renderer/services/commentReadStorage';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap, REVIEW_STATE_DISPLAY } from '@renderer/utils/memberHelpers';
@ -62,6 +63,7 @@ function formatUpdatedLabel(task: GlobalTask): string | null {
interface SidebarTaskItemProps { interface SidebarTaskItemProps {
task: GlobalTask; task: GlobalTask;
hideTeamName?: boolean; hideTeamName?: boolean;
hideProjectName?: boolean;
showTeamName?: boolean; showTeamName?: boolean;
/** The composite key "teamName:taskId" of the task being renamed, or null */ /** The composite key "teamName:taskId" of the task being renamed, or null */
renamingKey?: string | null; renamingKey?: string | null;
@ -76,6 +78,7 @@ interface SidebarTaskItemProps {
export const SidebarTaskItem = memo(function SidebarTaskItem({ export const SidebarTaskItem = memo(function SidebarTaskItem({
task, task,
hideTeamName, hideTeamName,
hideProjectName,
showTeamName, showTeamName,
renamingKey, renamingKey,
onRenameComplete, onRenameComplete,
@ -117,6 +120,11 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const) ? ({ icon: Eye, color: 'text-orange-400', label: 'in review' } as const)
: (statusConfig[task.status] ?? statusConfig.pending); : (statusConfig[task.status] ?? statusConfig.pending);
const StatusIcon = cfg.icon; const StatusIcon = cfg.icon;
const statusIconClassName = cn(
'size-3 shrink-0',
cfg.color,
cfg.label === 'in progress' && 'animate-spin'
);
const updatedLabel = formatUpdatedLabel(task); const updatedLabel = formatUpdatedLabel(task);
const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt); const dateLabel = updatedLabel ?? formatTaskDate(task.createdAt);
@ -133,9 +141,10 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
}, [ownerColorSet, isLight]); }, [ownerColorSet, isLight]);
const projectLabel = useMemo(() => { const projectLabel = useMemo(() => {
if (hideProjectName) return null;
if (!task.projectPath?.trim()) return null; if (!task.projectPath?.trim()) return null;
return projectLabelFromPath(task.projectPath); return projectLabelFromPath(task.projectPath);
}, [task.projectPath]); }, [hideProjectName, task.projectPath]);
const projectColorSet = useMemo( const projectColorSet = useMemo(
() => (projectLabel ? projectColor(projectLabel, isLight) : null), () => (projectLabel ? projectColor(projectLabel, isLight) : null),
@ -167,7 +176,7 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
<div className="w-full overflow-hidden"> <div className="w-full overflow-hidden">
{isRenaming ? ( {isRenaming ? (
<div className="flex items-start gap-1.5"> <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 <input
ref={inputRef} ref={inputRef}
type="text" type="text"
@ -207,7 +216,9 @@ export const SidebarTaskItem = memo(function SidebarTaskItem({
className="line-clamp-2 text-[13px] font-medium leading-tight" className="line-clamp-2 text-[13px] font-medium leading-tight"
style={{ color: 'var(--color-text-muted)' }} 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 > 0 &&
(unreadCount === 1 ? ( (unreadCount === 1 ? (
<span className="mr-1 inline-block size-1.5 rounded-full bg-blue-400 align-middle" /> <span className="mr-1 inline-block size-1.5 rounded-full bg-blue-400 align-middle" />

View file

@ -22,6 +22,19 @@ const PREVIEW_ICONS = {
tool: <Wrench size={12} className="shrink-0" />, tool: <Wrench size={12} className="shrink-0" />,
} as const; } 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 // Sub-components
// ============================================================================= // =============================================================================
@ -79,6 +92,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const isSidebar = position === 'sidebar'; const isSidebar = position === 'sidebar';
const showHeaderSkeleton = ctrl.loading && ctrl.data.lines.length === 0 && !ctrl.error;
const sectionHeaderExtra = useMemo( const sectionHeaderExtra = useMemo(
() => ( () => (
@ -90,11 +104,46 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
</span> </span>
) : null} ) : null}
{ctrl.lastLogPreview ? <LogPreviewInline preview={ctrl.lastLogPreview} /> : 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> </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 ( return (
<> <>
<CollapsibleTeamSection <CollapsibleTeamSection
@ -102,27 +151,7 @@ export const ClaudeLogsSection = memo(function ClaudeLogsSection({
title="Logs" title="Logs"
icon={null} icon={null}
badge={ctrl.badge} badge={ctrl.badge}
afterBadge={ afterBadge={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
}
headerClassName={isSidebar ? '-mx-3 w-[calc(100%+1.5rem)] py-0' : undefined} headerClassName={isSidebar ? '-mx-3 w-[calc(100%+1.5rem)] py-0' : undefined}
headerSurfaceClassName={isSidebar ? '!rounded-none' : undefined} headerSurfaceClassName={isSidebar ? '!rounded-none' : undefined}
headerContentClassName={isSidebar ? 'flex-wrap items-center gap-y-1 py-1 pr-1' : 'pr-1'} headerContentClassName={isSidebar ? 'flex-wrap items-center gap-y-1 py-1 pr-1' : 'pr-1'}

View file

@ -85,7 +85,11 @@ export const CollapsibleTeamSection = memo(function CollapsibleTeamSection({
}, [isOpen, onOpenChange]); }, [isOpen, onOpenChange]);
return ( 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 <div
className={cn( className={cn(
'relative -mx-[calc(1rem-5px)] flex min-h-9 w-[calc(100%+2rem-10px)] items-stretch py-1.5', 'relative -mx-[calc(1rem-5px)] flex min-h-9 w-[calc(100%+2rem-10px)] items-stretch py-1.5',

View file

@ -332,17 +332,23 @@ export const TeamChangesSection = memo(function TeamChangesSection({
{visibleFiles.map((file) => ( {visibleFiles.map((file) => (
<div <div
key={`${summary.taskId}:${file.filePath}`} 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" /> <FileIcon fileName={getVisibleFileName(file)} className="size-3.5" />
<button <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)]">
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)}
>
{getVisibleFilePath(file)} {getVisibleFilePath(file)}
</button> </span>
<span className="flex shrink-0 items-center gap-1.5"> <span className="flex shrink-0 items-center gap-1.5">
{file.linesAdded > 0 ? ( {file.linesAdded > 0 ? (
<span className="text-emerald-400">+{file.linesAdded}</span> <span className="text-emerald-400">+{file.linesAdded}</span>
@ -357,7 +363,10 @@ export const TeamChangesSection = memo(function TeamChangesSection({
<button <button
type="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)]" 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" aria-label="Review diff"
> >
<GitCompareArrows size={13} /> <GitCompareArrows size={13} />

View file

@ -60,13 +60,18 @@ import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { import {
AlertTriangle, AlertTriangle,
ChevronRight,
Clock, Clock,
Code, Code,
Columns3, Columns3,
Expand,
FolderOpen, FolderOpen,
GitBranch, GitBranch,
History, History,
MessageSquare,
MoreHorizontal,
Network, Network,
Paperclip,
Pencil, Pencil,
Play, Play,
Plus, Plus,
@ -176,6 +181,7 @@ interface CreateTaskDialogState {
const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000; const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000;
const MEMBER_ROSTER_HYDRATION_RETRY_DELAY_MS = 1_200; const MEMBER_ROSTER_HYDRATION_RETRY_DELAY_MS = 1_200;
const FLOATING_COMPOSER_SCROLL_RESERVE_BASE_PX = 200;
function getSummaryKnownTeammateCount(summary: TeamSummary | undefined): number { function getSummaryKnownTeammateCount(summary: TeamSummary | undefined): number {
if (!summary) { 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 => ( const TeamLoadingSidebarSkeleton = (): React.JSX.Element => (
<aside <aside
className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]" className="flex size-full min-h-0 flex-col overflow-hidden bg-[var(--color-surface)]"
aria-label="Loading team sidebar" aria-label="Loading team sidebar"
> >
<div className="shrink-0 px-3 py-2"> <div className="shrink-0 overflow-hidden px-3">
<div className="-mx-3 flex min-h-9 items-center gap-3 bg-[var(--color-section-bg)] px-4"> <section className="min-w-0">
<SkeletonPill className="size-4 rounded" /> <div className="relative -mx-3 flex min-h-9 w-[calc(100%+1.5rem)] items-stretch py-0">
<SkeletonPill className="h-4 w-16" /> <div className="absolute inset-0 z-0 bg-[var(--color-section-bg)]" />
<SkeletonPill className="h-5 w-8" /> <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">
<SkeletonPill className="ml-auto size-5 rounded" /> <ChevronRight
</div> size={14}
<div className="mt-3 flex items-center gap-2"> className="shrink-0 text-[var(--color-text-muted)] transition-transform duration-150"
<SkeletonPill className="size-4 rounded" /> />
<SkeletonPill className="h-3.5 w-44" /> <SkeletonPill className="h-4 w-14" />
</div> <SkeletonPill className="h-5 w-14" />
</div> <span className="pointer-events-auto ml-auto inline-flex size-6 items-center justify-center rounded text-[var(--color-text-muted)] opacity-70">
<div className="h-px shrink-0 bg-[var(--color-border)]" /> <Expand size={14} />
<div className="flex min-h-0 flex-1 flex-col overflow-hidden p-3"> </span>
<div className="mb-3 flex min-h-9 items-center gap-3"> <span className="flex min-w-0 basis-full items-center gap-1.5 opacity-70">
<SkeletonPill className="size-4 rounded" /> <MessageSquare size={12} className="shrink-0 text-[var(--color-text-muted)]" />
<SkeletonPill className="h-4 w-24" /> <SkeletonPill className="h-3 w-12 rounded" />
<SkeletonPill className="h-5 w-8" /> <SkeletonPill className="h-3 w-2 rounded" />
<div className="ml-auto flex items-center gap-3"> <SkeletonPill className="h-3 min-w-0 flex-1 rounded" />
<SkeletonPill className="size-5 rounded" /> </span>
<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> </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>
</div> </div>
</aside> </aside>
@ -431,9 +485,10 @@ const TeamLoadingSectionHeader = ({
)} )}
/> />
<div className="relative z-10 flex min-w-0 flex-1 items-center gap-2 pl-4"> <div className="relative z-10 flex min-w-0 flex-1 items-center gap-2 pl-4">
<span <ChevronRight
size={14}
className={cn( 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' open && 'rotate-90'
)} )}
/> />
@ -496,7 +551,7 @@ const TeamContentLoadingSkeleton = ({
<SkeletonPill className="h-5 w-24 rounded-md" /> <SkeletonPill className="h-5 w-24 rounded-md" />
<SkeletonPill className="h-3 w-16" /> <SkeletonPill className="h-3 w-16" />
</div> </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>
</div> </div>
@ -504,7 +559,7 @@ const TeamContentLoadingSkeleton = ({
<TeamProvisioningBanner teamName={teamName} /> <TeamProvisioningBanner teamName={teamName} />
</div> </div>
<section className="min-w-0"> <section className="min-w-0 [&:not(:last-child)]:mb-[10px]">
<TeamLoadingSectionHeader <TeamLoadingSectionHeader
icon={<Users size={14} />} icon={<Users size={14} />}
titleWidth="w-20" titleWidth="w-20"
@ -513,14 +568,17 @@ const TeamContentLoadingSkeleton = ({
/> />
<div className="mt-3 grid grid-cols-1 gap-1 pb-4"> <div className="mt-3 grid grid-cols-1 gap-1 pb-4">
{TEAM_LOADING_MEMBER_ACCENTS.map((accent, index) => ( {TEAM_LOADING_MEMBER_ACCENTS.map((accent, index) => (
<div key={accent} className="flex min-h-[52px] min-w-0 items-center gap-3"> <div key={accent} className="flex min-h-[52px] min-w-0 items-center gap-2.5">
<div className="relative size-7 shrink-0"> <div className="relative size-[34px] shrink-0">
<div <div
className="absolute inset-0 rounded-full border-2 bg-[var(--color-surface-raised)]" 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 <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 }} style={{ backgroundColor: accent }}
/> />
</div> </div>
@ -535,19 +593,19 @@ const TeamContentLoadingSkeleton = ({
<div className="hidden shrink-0 items-center gap-3 sm:flex"> <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="h-[18px] w-[62px]" /> <SkeletonPill className="h-[18px] w-[62px]" />
<SkeletonPill className="size-4 rounded" /> <SkeletonPill className="size-[21px] rounded" />
<SkeletonPill className="size-4 rounded" /> <SkeletonPill className="size-[21px] rounded" />
</div> </div>
</div> </div>
))} ))}
</div> </div>
</section> </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} /> <TeamLoadingSectionHeader icon={<History size={14} />} titleWidth="w-24" open={false} />
</section> </section>
<section className="mt-0 min-w-0"> <section className="min-w-0 [&:not(:last-child)]:mb-[10px]">
<TeamLoadingSectionHeader <TeamLoadingSectionHeader
icon={<Columns3 size={14} />} icon={<Columns3 size={14} />}
titleWidth="w-24" titleWidth="w-24"
@ -564,15 +622,15 @@ const TeamContentLoadingSkeleton = ({
<SkeletonBlock className="h-9 w-28" /> <SkeletonBlock className="h-9 w-28" />
</div> </div>
</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) => ( {TEAM_LOADING_KANBAN_COLUMNS.map((column) => (
<div <div
key={column.title} 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 }} style={{ backgroundColor: column.bodyBg }}
> >
<div <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 }} style={{ backgroundColor: column.headerBg }}
> >
<SkeletonPill className="size-4 rounded" /> <SkeletonPill className="size-4 rounded" />
@ -580,9 +638,9 @@ const TeamContentLoadingSkeleton = ({
className={cn('h-4', column.title === 'IN PROGRESS' ? 'w-32' : 'w-20')} className={cn('h-4', column.title === 'IN PROGRESS' ? 'w-32' : 'w-20')}
/> />
</div> </div>
<div className="p-4"> <div className="min-h-0 flex-1 overflow-hidden p-2">
<div <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={{ style={{
backgroundColor: 'color-mix(in srgb, var(--color-surface) 35%, transparent)', 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>( const [messagesPanelMountPoint, setMessagesPanelMountPoint] = useState<HTMLDivElement | null>(
null null
); );
const [floatingComposerHeight, setFloatingComposerHeight] = useState(0);
const provisioningBannerRef = useRef<HTMLDivElement>(null); const provisioningBannerRef = useRef<HTMLDivElement>(null);
const wasProvisioningRef = useRef(false); const wasProvisioningRef = useRef(false);
const handleFloatingComposerHeightChange = useCallback((height: number) => {
setFloatingComposerHeight((currentHeight) =>
currentHeight === height ? currentHeight : height
);
}, []);
const handleOpenGraphTab = useCallback(() => { const handleOpenGraphTab = useCallback(() => {
const state = useStore.getState(); const state = useStore.getState();
const displayName = state.teamByName[teamName]?.displayName ?? teamName; const displayName = state.teamByName[teamName]?.displayName ?? teamName;
@ -1410,152 +1474,6 @@ export const TeamDetailView = memo(function TeamDetailView({
return () => window.removeEventListener('toggle-team-graph', handler); return () => window.removeEventListener('toggle-team-graph', handler);
}, [handleOpenGraphTab, teamName]); }, [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 [sendDialogOpen, setSendDialogOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [stoppingTeam, setStoppingTeam] = useState(false); const [stoppingTeam, setStoppingTeam] = useState(false);
@ -2548,6 +2466,7 @@ export const TeamDetailView = memo(function TeamDetailView({
onReplyToMessage: handleReplyToMessage, onReplyToMessage: handleReplyToMessage,
onRestartTeam: handleRestartTeam, onRestartTeam: handleRestartTeam,
onTaskIdClick: handleTaskIdClick, onTaskIdClick: handleTaskIdClick,
onFloatingComposerHeightChange: handleFloatingComposerHeightChange,
inlineScrollContainerRef: contentRef, inlineScrollContainerRef: contentRef,
}), }),
[ [
@ -2560,6 +2479,7 @@ export const TeamDetailView = memo(function TeamDetailView({
handleRestartTeam, handleRestartTeam,
handleSelectMember, handleSelectMember,
handleTaskIdClick, handleTaskIdClick,
handleFloatingComposerHeightChange,
messagesPanelTasks, messagesPanelTasks,
messagesPanelMountPoint, messagesPanelMountPoint,
pendingRepliesByMember, pendingRepliesByMember,
@ -2695,6 +2615,11 @@ export const TeamDetailView = memo(function TeamDetailView({
const headerColorSet = data.config.color const headerColorSet = data.config.color
? getTeamColorSet(data.config.color) ? getTeamColorSet(data.config.color)
: nameColorSet(data.config.name); : nameColorSet(data.config.name);
const shouldReserveFloatingComposerScrollSpace =
messagesPanelMode === 'floating-composer' && isThisTabActive && isPaneFocused && !graphOpen;
const floatingComposerScrollReserve = shouldReserveFloatingComposerScrollSpace
? FLOATING_COMPOSER_SCROLL_RESERVE_BASE_PX + floatingComposerHeight
: undefined;
return ( return (
<> <>
@ -2737,6 +2662,7 @@ export const TeamDetailView = memo(function TeamDetailView({
<div <div
ref={contentRef} ref={contentRef}
className="size-full min-w-0 overflow-y-auto overflow-x-hidden p-4" className="size-full min-w-0 overflow-y-auto overflow-x-hidden p-4"
style={{ paddingBottom: floatingComposerScrollReserve }}
data-team-name={teamName} 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"> <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> </Button>
</div> </div>
} }
contentWrapperClassName="-mx-[calc(1rem-5px)] w-[calc(100%+2rem-10px)]"
> >
<TeamMemberListBridge <div className="px-[calc(1rem-5px)]">
teamName={teamName} <TeamMemberListBridge
members={membersWithLiveBranches} teamName={teamName}
expectedTeammateCount={activeTeammateCount} members={membersWithLiveBranches}
memberTaskCounts={memberTaskCounts} expectedTeammateCount={activeTeammateCount}
taskMap={taskMap} memberTaskCounts={memberTaskCounts}
pendingRepliesByMember={pendingRepliesByMember} taskMap={taskMap}
isTeamAlive={data.isAlive} pendingRepliesByMember={pendingRepliesByMember}
isTeamProvisioning={isTeamProvisioning} isRosterLoading={loading}
launchParams={launchParams} isTeamAlive={data.isAlive}
onMemberClick={handleSelectMember} isTeamProvisioning={isTeamProvisioning}
onSendMessage={handleSendMessageToMember} launchParams={launchParams}
onAssignTask={handleAssignTaskToMember} onMemberClick={handleSelectMember}
onOpenTask={handleOpenTaskById} onSendMessage={handleSendMessageToMember}
onRestartMember={handleRestartMember} onAssignTask={handleAssignTaskToMember}
onSkipMemberForLaunch={handleSkipMemberForLaunch} onOpenTask={handleOpenTaskById}
/> onRestartMember={handleRestartMember}
onSkipMemberForLaunch={handleSkipMemberForLaunch}
/>
</div>
</CollapsibleTeamSection> </CollapsibleTeamSection>
<CollapsibleTeamSection <CollapsibleTeamSection
@ -3665,26 +3595,6 @@ export const TeamDetailView = memo(function TeamDetailView({
isThisTabActive && isThisTabActive &&
isPaneFocused 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> </Suspense>
)} )}

View file

@ -55,6 +55,7 @@ import { useShallow } from 'zustand/react/shallow';
import { TeamEmptyState } from './TeamEmptyState'; import { TeamEmptyState } from './TeamEmptyState';
import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover'; import { EMPTY_TEAM_FILTER, TeamListFilterPopover } from './TeamListFilterPopover';
import { executeTeamRelaunch } from './dialogs/teamRelaunchFlow';
import { import {
findTeamProjectSelectionTarget, findTeamProjectSelectionTarget,
resolveTeamProjectSelection, resolveTeamProjectSelection,
@ -63,6 +64,7 @@ import {
import { TeamTaskStatusSummary } from './TeamTaskStatusSummary'; import { TeamTaskStatusSummary } from './TeamTaskStatusSummary';
import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog'; import type { ActiveTeamRef, TeamCopyData } from './dialogs/CreateTeamDialog';
import type { TeamLaunchDialogMode } from './dialogs/LaunchTeamDialog';
import type { TeamListFilterState } from './TeamListFilterPopover'; import type { TeamListFilterState } from './TeamListFilterPopover';
import type { TeamStatus } from '@renderer/utils/teamListStatus'; import type { TeamStatus } from '@renderer/utils/teamListStatus';
import type { import type {
@ -267,6 +269,7 @@ interface ActiveTeamCardProps {
onLaunchTeam: ( onLaunchTeam: (
teamName: string, teamName: string,
projectPath: string | undefined, projectPath: string | undefined,
mode: TeamLaunchDialogMode,
event: React.MouseEvent event: React.MouseEvent
) => void; ) => void;
onStopTeam: (teamName: string, event: React.MouseEvent) => void; onStopTeam: (teamName: string, event: React.MouseEvent) => void;
@ -297,6 +300,8 @@ const ActiveTeamCard = ({
status === 'partial_skipped' || status === 'partial_skipped' ||
status === 'partial_pending') && status === 'partial_pending') &&
Boolean(team.projectPath); Boolean(team.projectPath);
const launchMode: TeamLaunchDialogMode = status === 'offline' ? 'launch' : 'relaunch';
const launchLabel = launchMode === 'relaunch' ? 'Relaunch team' : 'Launch team';
return ( return (
<div <div
@ -347,16 +352,21 @@ const ActiveTeamCard = ({
type="button" 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" 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) => onClick={(event) =>
onLaunchTeam(team.teamName, team.projectPath ?? undefined, event) onLaunchTeam(
team.teamName,
team.projectPath ?? undefined,
launchMode,
event
)
} }
disabled={launchingTeamName === team.teamName} disabled={launchingTeamName === team.teamName}
aria-label="Launch team" aria-label={launchLabel}
> >
<Play size={14} fill="currentColor" /> <Play size={14} fill="currentColor" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="bottom"> <TooltipContent side="bottom">
{launchingTeamName === team.teamName ? 'Launching…' : 'Launch team'} {launchingTeamName === team.teamName ? 'Launching…' : launchLabel}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
@ -867,18 +877,25 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
const [launchingTeamName, setLaunchingTeamName] = useState<string | null>(null); const [launchingTeamName, setLaunchingTeamName] = useState<string | null>(null);
const [launchDialogOpen, setLaunchDialogOpen] = useState(false); const [launchDialogOpen, setLaunchDialogOpen] = useState(false);
const [launchDialogMode, setLaunchDialogMode] = useState<TeamLaunchDialogMode>('launch');
const [launchDialogTeamName, setLaunchDialogTeamName] = useState(''); const [launchDialogTeamName, setLaunchDialogTeamName] = useState('');
const [launchDialogMembers, setLaunchDialogMembers] = useState<ResolvedTeamMember[]>([]); const [launchDialogMembers, setLaunchDialogMembers] = useState<ResolvedTeamMember[]>([]);
const [launchDialogDefaultPath, setLaunchDialogDefaultPath] = useState<string | undefined>(); const [launchDialogDefaultPath, setLaunchDialogDefaultPath] = useState<string | undefined>();
const handleLaunchTeam = useCallback( 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(); e.stopPropagation();
if (!projectPath) return; if (!projectPath) return;
try { try {
const data = await api.teams.getData(teamName, { const data = await api.teams.getData(teamName, {
includeMemberBranches: false, includeMemberBranches: false,
}); });
setLaunchDialogMode(mode);
setLaunchDialogTeamName(teamName); setLaunchDialogTeamName(teamName);
setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? [])); setLaunchDialogMembers(resolveLaunchDialogMembers(data.members ?? []));
setLaunchDialogDefaultPath(data.config.projectPath ?? projectPath); 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); console.error('Failed to load team data for launch dialog:', err);
} }
// Fallback: open dialog with minimal data // Fallback: open dialog with minimal data
setLaunchDialogMode(mode);
setLaunchDialogTeamName(teamName); setLaunchDialogTeamName(teamName);
setLaunchDialogMembers([]); setLaunchDialogMembers([]);
setLaunchDialogDefaultPath(projectPath); setLaunchDialogDefaultPath(projectPath);
@ -913,6 +931,30 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
[launchTeam] [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(() => { useEffect(() => {
if (!electronMode) { if (!electronMode) {
return; return;
@ -982,18 +1024,33 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
const launchDialogElement = launchDialogOpen && ( const launchDialogElement = launchDialogOpen && (
<Suspense fallback={null}> <Suspense fallback={null}>
<LaunchTeamDialog {launchDialogMode === 'relaunch' ? (
mode="launch" <LaunchTeamDialog
open={launchDialogOpen} mode="relaunch"
teamName={launchDialogTeamName} open={launchDialogOpen}
members={launchDialogMembers} teamName={launchDialogTeamName}
defaultProjectPath={launchDialogDefaultPath} members={launchDialogMembers}
provisioningError={provisioningErrorByTeam[launchDialogTeamName] ?? null} defaultProjectPath={launchDialogDefaultPath}
clearProvisioningError={clearProvisioningError} provisioningError={provisioningErrorByTeam[launchDialogTeamName] ?? null}
activeTeams={activeTeams} clearProvisioningError={clearProvisioningError}
onClose={() => setLaunchDialogOpen(false)} activeTeams={activeTeams}
onLaunch={handleLaunchSubmit} 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> </Suspense>
); );

View file

@ -155,6 +155,15 @@ function malformedLegacyChangeSet(): TaskChangeSetV2 {
} as unknown as TaskChangeSetV2; } as unknown as TaskChangeSetV2;
} }
function malformedUnknownChangeSet(): TaskChangeSetV2 {
return {
...changeSet(),
confidence: 'fallback',
files: undefined,
warnings: undefined,
} as unknown as TaskChangeSetV2;
}
function malformedResponse(): TeamTaskChangeSummariesResponse { function malformedResponse(): TeamTaskChangeSummariesResponse {
return { return {
teamName: 'team-a', teamName: 'team-a',
@ -196,6 +205,25 @@ function incompleteChangeSetResponse(): TeamTaskChangeSummariesResponse {
} as unknown as 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 { function lowConfidenceFileResponse(): TeamTaskChangeSummariesResponse {
return response({ return response({
...changeSet(), ...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 () => { it('shows the closed-section counter only after the background count load resolves', async () => {
const deferred = createDeferred<TeamTaskChangeSummariesResponse>(); const deferred = createDeferred<TeamTaskChangeSummariesResponse>();
hoisted.getTeamTaskChangeSummaries.mockReturnValue(deferred.promise); hoisted.getTeamTaskChangeSummaries.mockReturnValue(deferred.promise);
@ -1222,6 +1404,32 @@ describe('useTeamChangesSummaries', () => {
}); });
expect(onViewChanges).toHaveBeenCalledWith('task-1'); 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 { } finally {
if (scrollIntoViewDescriptor) { if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor); Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);

View file

@ -103,8 +103,8 @@ import { loadProjectPathProjects, type ProjectPathProject } from './projectPathP
import { ProjectPathSelector } from './ProjectPathSelector'; import { ProjectPathSelector } from './ProjectPathSelector';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
import { import {
buildReusableProviderPrepareModelResults,
getProviderPrepareCachedSnapshot, getProviderPrepareCachedSnapshot,
mergeReusableProviderPrepareModelResults,
type ProviderPrepareDiagnosticsModelResult, type ProviderPrepareDiagnosticsModelResult,
runProviderPrepareDiagnostics, runProviderPrepareDiagnostics,
} from './providerPrepareDiagnostics'; } from './providerPrepareDiagnostics';
@ -776,6 +776,9 @@ export const CreateTeamDialog = ({
] ]
); );
const shortLivedModelIssueReasons = useMemo(() => { const shortLivedModelIssueReasons = useMemo(() => {
void prepareChecks;
const modelAdvisoryReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> =
{};
const modelIssueReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> = {}; const modelIssueReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> = {};
const modelUnavailableReasonByProvider: Partial< const modelUnavailableReasonByProvider: Partial<
Record<TeamProviderId, Record<string, string>> Record<TeamProviderId, Record<string, string>>
@ -794,6 +797,9 @@ export const CreateTeamDialog = ({
providerId, providerId,
cacheKey, cacheKey,
}); });
if (Object.keys(issueReasons.modelAdvisoryReasonByValue).length > 0) {
modelAdvisoryReasonByProvider[providerId] = issueReasons.modelAdvisoryReasonByValue;
}
if (Object.keys(issueReasons.modelIssueReasonByValue).length > 0) { if (Object.keys(issueReasons.modelIssueReasonByValue).length > 0) {
modelIssueReasonByProvider[providerId] = issueReasons.modelIssueReasonByValue; modelIssueReasonByProvider[providerId] = issueReasons.modelIssueReasonByValue;
} }
@ -803,12 +809,14 @@ export const CreateTeamDialog = ({
} }
return { return {
modelAdvisoryReasonByProvider,
modelIssueReasonByProvider, modelIssueReasonByProvider,
modelUnavailableReasonByProvider, modelUnavailableReasonByProvider,
}; };
}, [ }, [
effectiveAnthropicRuntimeLimitContext, effectiveAnthropicRuntimeLimitContext,
effectiveCwd, effectiveCwd,
prepareChecks,
prepareRuntimeStatusSignature, prepareRuntimeStatusSignature,
runtimeBackendSummaryByProvider, runtimeBackendSummaryByProvider,
selectedMemberProviders, selectedMemberProviders,
@ -1037,10 +1045,13 @@ export const CreateTeamDialog = ({
anyNotes = true; anyNotes = true;
} }
if (prepareRequestSeqRef.current === requestSeq) { if (prepareRequestSeqRef.current === requestSeq) {
const reusableModelResults = buildReusableProviderPrepareModelResults( prepareModelResultsCacheRef.current.set(
plan.prepResult.modelResultsById plan.cacheKey,
mergeReusableProviderPrepareModelResults(
prepareModelResultsCacheRef.current.get(plan.cacheKey),
plan.prepResult.modelResultsById
)
); );
prepareModelResultsCacheRef.current.set(plan.cacheKey, reusableModelResults);
storeShortLivedProviderPrepareModelResults({ storeShortLivedProviderPrepareModelResults({
providerId: plan.providerId, providerId: plan.providerId,
cacheKey: plan.cacheKey, cacheKey: plan.cacheKey,
@ -1499,7 +1510,11 @@ export const CreateTeamDialog = ({
teamName: sanitizedTeamName, teamName: sanitizedTeamName,
description: description.trim() || undefined, description: description.trim() || undefined,
color: teamColor || undefined, color: teamColor || undefined,
members: soloTeam ? [] : buildMembersFromDrafts(effectiveMemberDrafts), members: soloTeam
? []
: buildMembersFromDrafts(effectiveMemberDrafts, {
inheritedProviderId: selectedProviderId,
}),
cwd: effectiveCwd, cwd: effectiveCwd,
prompt: prompt.trim() || undefined, prompt: prompt.trim() || undefined,
providerId: selectedProviderId, providerId: selectedProviderId,
@ -2064,6 +2079,9 @@ export const CreateTeamDialog = ({
leadModelIssueText={leadModelIssueText} leadModelIssueText={leadModelIssueText}
memberWarningById={teammateRuntimeCompatibility.memberWarningById} memberWarningById={teammateRuntimeCompatibility.memberWarningById}
memberModelIssueById={memberModelIssueById} memberModelIssueById={memberModelIssueById}
modelAdvisoryReasonByProvider={
shortLivedModelIssueReasons.modelAdvisoryReasonByProvider
}
modelIssueReasonByProvider={shortLivedModelIssueReasons.modelIssueReasonByProvider} modelIssueReasonByProvider={shortLivedModelIssueReasons.modelIssueReasonByProvider}
modelUnavailableReasonByProvider={ modelUnavailableReasonByProvider={
shortLivedModelIssueReasons.modelUnavailableReasonByProvider shortLivedModelIssueReasons.modelUnavailableReasonByProvider

View file

@ -107,8 +107,8 @@ import { loadProjectPathProjects, type ProjectPathProject } from './projectPathP
import { ProjectPathSelector } from './ProjectPathSelector'; import { ProjectPathSelector } from './ProjectPathSelector';
import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey'; import { buildProviderPrepareModelCacheKey } from './providerPrepareCacheKey';
import { import {
buildReusableProviderPrepareModelResults,
getProviderPrepareCachedSnapshot, getProviderPrepareCachedSnapshot,
mergeReusableProviderPrepareModelResults,
type ProviderPrepareDiagnosticsModelResult, type ProviderPrepareDiagnosticsModelResult,
runProviderPrepareDiagnostics, runProviderPrepareDiagnostics,
} from './providerPrepareDiagnostics'; } from './providerPrepareDiagnostics';
@ -1430,6 +1430,8 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
] ]
); );
const shortLivedModelIssueReasons = useMemo(() => { const shortLivedModelIssueReasons = useMemo(() => {
const modelAdvisoryReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> =
{};
const modelIssueReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> = {}; const modelIssueReasonByProvider: Partial<Record<TeamProviderId, Record<string, string>>> = {};
const modelUnavailableReasonByProvider: Partial< const modelUnavailableReasonByProvider: Partial<
Record<TeamProviderId, Record<string, string>> Record<TeamProviderId, Record<string, string>>
@ -1437,6 +1439,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
if (!isLaunchMode) { if (!isLaunchMode) {
return { return {
modelAdvisoryReasonByProvider,
modelIssueReasonByProvider, modelIssueReasonByProvider,
modelUnavailableReasonByProvider, modelUnavailableReasonByProvider,
}; };
@ -1455,6 +1458,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
providerId, providerId,
cacheKey, cacheKey,
}); });
if (Object.keys(issueReasons.modelAdvisoryReasonByValue).length > 0) {
modelAdvisoryReasonByProvider[providerId] = issueReasons.modelAdvisoryReasonByValue;
}
if (Object.keys(issueReasons.modelIssueReasonByValue).length > 0) { if (Object.keys(issueReasons.modelIssueReasonByValue).length > 0) {
modelIssueReasonByProvider[providerId] = issueReasons.modelIssueReasonByValue; modelIssueReasonByProvider[providerId] = issueReasons.modelIssueReasonByValue;
} }
@ -1464,6 +1470,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
} }
return { return {
modelAdvisoryReasonByProvider,
modelIssueReasonByProvider, modelIssueReasonByProvider,
modelUnavailableReasonByProvider, modelUnavailableReasonByProvider,
}; };
@ -1615,10 +1622,13 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
anyNotes = true; anyNotes = true;
} }
if (prepareRequestSeqRef.current === requestSeq) { if (prepareRequestSeqRef.current === requestSeq) {
const reusableModelResults = buildReusableProviderPrepareModelResults( prepareModelResultsCacheRef.current.set(
plan.prepResult.modelResultsById plan.cacheKey,
mergeReusableProviderPrepareModelResults(
prepareModelResultsCacheRef.current.get(plan.cacheKey),
plan.prepResult.modelResultsById
)
); );
prepareModelResultsCacheRef.current.set(plan.cacheKey, reusableModelResults);
storeShortLivedProviderPrepareModelResults({ storeShortLivedProviderPrepareModelResults({
providerId: plan.providerId, providerId: plan.providerId,
cacheKey: plan.cacheKey, cacheKey: plan.cacheKey,
@ -2108,7 +2118,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
void (async () => { void (async () => {
try { try {
if (isLaunchMode) { if (isLaunchMode) {
const nextMembers = buildMembersFromDrafts(effectiveMemberDrafts); const nextMembers = buildMembersFromDrafts(effectiveMemberDrafts, {
inheritedProviderId: selectedProviderId,
});
const launchRequest: TeamLaunchRequest = { const launchRequest: TeamLaunchRequest = {
teamName: effectiveTeamName, teamName: effectiveTeamName,
cwd: effectiveCwd, cwd: effectiveCwd,
@ -2316,7 +2328,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
}} }}
> >
<DialogContent <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> <DialogHeader>
<DialogTitle className="text-sm">{dialogTitle}</DialogTitle> <DialogTitle className="text-sm">{dialogTitle}</DialogTitle>
@ -2616,6 +2628,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
memberInfoById={memberWorktreeContinuationInfoById} memberInfoById={memberWorktreeContinuationInfoById}
leadModelIssueText={leadModelIssueText} leadModelIssueText={leadModelIssueText}
memberModelIssueById={memberModelIssueById} memberModelIssueById={memberModelIssueById}
modelAdvisoryReasonByProvider={
shortLivedModelIssueReasons.modelAdvisoryReasonByProvider
}
modelIssueReasonByProvider={ modelIssueReasonByProvider={
shortLivedModelIssueReasons.modelIssueReasonByProvider shortLivedModelIssueReasons.modelIssueReasonByProvider
} }

View file

@ -583,6 +583,15 @@ export const TaskDetailDialog = ({
}); });
}, [requestTaskChangeSummary]); }, [requestTaskChangeSummary]);
const handleTaskChangeFileOpen = useCallback(
(filePath: string): void => {
if (!currentTask || !onViewChanges) return;
handleClose();
onViewChanges(currentTask.id, filePath);
},
[currentTask, handleClose, onViewChanges]
);
const handleDependencyClick = (taskId: string): void => { const handleDependencyClick = (taskId: string): void => {
// Resolve short displayId (e.g. "8ce74455") to full UUID via taskMap, // Resolve short displayId (e.g. "8ce74455") to full UUID via taskMap,
// since kanban cards use the full UUID in data-task-id. // since kanban cards use the full UUID in data-task-id.
@ -1301,28 +1310,36 @@ export const TaskDetailDialog = ({
{taskChangesFiles.map((file) => ( {taskChangesFiles.map((file) => (
<div <div
key={file.filePath} 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 <FileIcon
fileName={file.relativePath.split(/[\\/]/).pop() ?? file.relativePath} fileName={file.relativePath.split(/[\\/]/).pop() ?? file.relativePath}
className="size-3.5" className="size-3.5"
/> />
{onViewChanges ? ( <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)]">
<button {file.relativePath}
type="button" </span>
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="flex shrink-0 items-center gap-1.5"> <span className="flex shrink-0 items-center gap-1.5">
{file.linesAdded > 0 ? ( {file.linesAdded > 0 ? (
<span className="text-emerald-400">+{file.linesAdded}</span> <span className="text-emerald-400">+{file.linesAdded}</span>
@ -1338,9 +1355,9 @@ export const TaskDetailDialog = ({
<button <button
type="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)]" className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-border-emphasis)] hover:text-[var(--color-text)]"
onClick={() => { onClick={(event) => {
handleClose(); event.stopPropagation();
onViewChanges(currentTask.id, file.filePath); handleTaskChangeFileOpen(file.filePath);
}} }}
> >
<GitCompareArrows size={13} /> <GitCompareArrows size={13} />
@ -1355,7 +1372,10 @@ export const TaskDetailDialog = ({
<button <button
type="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)]" 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} /> <SquarePen size={13} />
</button> </button>

View file

@ -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 { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
import { Checkbox } from '@renderer/components/ui/checkbox'; import { Checkbox } from '@renderer/components/ui/checkbox';
@ -35,12 +35,12 @@ import {
import { import {
compareTeamModelRecommendations, compareTeamModelRecommendations,
getTeamModelRecommendation, getTeamModelRecommendation,
isTeamModelRecommended,
} from '@renderer/utils/teamModelRecommendations'; } from '@renderer/utils/teamModelRecommendations';
import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel';
import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults';
import { parseOpenCodeQualifiedModelRef } from '@shared/utils/opencodeModelRef'; import { parseOpenCodeQualifiedModelRef } from '@shared/utils/opencodeModelRef';
import { isTeamProviderId } from '@shared/utils/teamProvider'; import { isTeamProviderId } from '@shared/utils/teamProvider';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Command as CommandPrimitive } from 'cmdk'; import { Command as CommandPrimitive } from 'cmdk';
import { import {
AlertTriangle, AlertTriangle,
@ -71,12 +71,44 @@ interface OpenCodeSourceOption {
count: number; count: number;
} }
interface OpenCodeSourceInfo {
id: string;
label: string;
}
interface OpenCodeModelGroup { interface OpenCodeModelGroup {
sourceId: string; sourceId: string;
sourceLabel: string; sourceLabel: string;
options: TeamRuntimeModelOption[]; 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]; type ProviderModelCatalogItem = NonNullable<CliProviderStatus['modelCatalog']>['models'][number];
interface OpenCodeModelCostRates { interface OpenCodeModelCostRates {
@ -92,6 +124,13 @@ interface OpenCodeModelPricingInfo {
title: string | undefined; 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[] = [ const PROVIDERS: ProviderDef[] = [
{ id: 'anthropic', label: 'Anthropic', comingSoon: false }, { id: 'anthropic', label: 'Anthropic', comingSoon: false },
{ id: 'codex', label: 'Codex', comingSoon: false }, { id: 'codex', label: 'Codex', comingSoon: false },
@ -99,7 +138,7 @@ const PROVIDERS: ProviderDef[] = [
{ id: 'opencode', label: 'OpenCode', comingSoon: false }, { 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); const parsed = parseOpenCodeQualifiedModelRef(model);
if (!parsed) { if (!parsed) {
return null; 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 { function getRecordValue(record: Record<string, unknown>, keys: string[]): unknown {
for (const key of keys) { for (const key of keys) {
if (key in record) { 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 { export interface TeamModelSelectorProps {
providerId: TeamProviderId; providerId: TeamProviderId;
onProviderChange: (providerId: TeamProviderId) => void; onProviderChange: (providerId: TeamProviderId) => void;
@ -292,6 +522,7 @@ export interface TeamModelSelectorProps {
disableGeminiOption?: boolean; disableGeminiOption?: boolean;
providerDisabledReasonById?: Partial<Record<TeamProviderId, string | null | undefined>>; providerDisabledReasonById?: Partial<Record<TeamProviderId, string | null | undefined>>;
providerDisabledBadgeLabelById?: 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>>; modelIssueReasonByValue?: Partial<Record<string, string | null | undefined>>;
modelUnavailableReasonByValue?: 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, disableGeminiOption = false,
providerDisabledReasonById, providerDisabledReasonById,
providerDisabledBadgeLabelById, providerDisabledBadgeLabelById,
modelAdvisoryReasonByValue,
modelIssueReasonByValue, modelIssueReasonByValue,
modelUnavailableReasonByValue, modelUnavailableReasonByValue,
}) => { }) => {
@ -462,22 +694,50 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
for (const model of catalog.models) { for (const model of catalog.models) {
const launchModel = model.launchModel.trim(); const launchModel = model.launchModel.trim();
const id = model.id.trim(); const catalogModelId = model.id.trim();
if (launchModel) { if (launchModel) {
modelById.set(launchModel, model); modelById.set(launchModel, model);
} }
if (id) { if (catalogModelId) {
modelById.set(id, model); modelById.set(catalogModelId, model);
} }
} }
return modelById; return modelById;
}, [effectiveProviderId, runtimeProviderStatus?.modelCatalog]); }, [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( const hasRecommendedOpenCodeModels = useMemo(
() => () => openCodeModelMetadata.some((metadata) => metadata.isRecommended),
effectiveProviderId === 'opencode' && [openCodeModelMetadata]
modelOptions.some((option) => isTeamModelRecommended(effectiveProviderId, option.value)),
[effectiveProviderId, modelOptions]
); );
useEffect(() => { useEffect(() => {
@ -511,15 +771,16 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
} }
const sourceOptions = new Map<string, OpenCodeSourceOption>(); const sourceOptions = new Map<string, OpenCodeSourceOption>();
for (const option of modelOptions) { for (const metadata of openCodeModelMetadata) {
const option = metadata.option;
if (!option.value.trim()) { if (!option.value.trim()) {
continue; continue;
} }
if (recommendedOnly && !isTeamModelRecommended(effectiveProviderId, option.value)) { if (recommendedOnly && !metadata.isRecommended) {
continue; continue;
} }
const sourceInfo = getOpenCodeSourceInfo(option.value); const sourceInfo = metadata.sourceInfo;
if (!sourceInfo) { if (!sourceInfo) {
continue; continue;
} }
@ -535,7 +796,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return Array.from(sourceOptions.values()).sort((left, right) => return Array.from(sourceOptions.values()).sort((left, right) =>
left.label.localeCompare(right.label, undefined, { sensitivity: 'base' }) left.label.localeCompare(right.label, undefined, { sensitivity: 'base' })
); );
}, [effectiveProviderId, modelOptions, recommendedOnly]); }, [effectiveProviderId, openCodeModelMetadata, recommendedOnly]);
useEffect(() => { useEffect(() => {
if (selectedOpenCodeSourceIds.size === 0) { 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 visibleModelOptions = useMemo(() => {
const normalizedModelQuery = modelQuery.trim().toLowerCase(); const normalizedModelQuery = modelQuery.trim().toLowerCase();
const matchesModelQuery = (option: (typeof modelOptions)[number]): boolean => { const matchesModelQuery = (option: (typeof modelOptions)[number]): boolean => {
@ -595,18 +904,12 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return true; return true;
} }
const modelRecommendation = getTeamModelRecommendation(effectiveProviderId, option.value); const modelRecommendation = getTeamModelRecommendation(effectiveProviderId, option.value);
const openCodePricingInfo = getOpenCodeModelPricingInfo(
openCodeCatalogModelById.get(option.value)
);
return [ return [
option.value, option.value,
option.label, option.label,
option.badgeLabel ?? '', option.badgeLabel ?? '',
getOpenCodeSourceInfo(option.value)?.label ?? '',
modelRecommendation?.label ?? '', modelRecommendation?.label ?? '',
modelRecommendation?.reason ?? '', modelRecommendation?.reason ?? '',
openCodePricingInfo?.free ? 'free' : '',
openCodePricingInfo?.summary ?? '',
] ]
.join(' ') .join(' ')
.toLowerCase() .toLowerCase()
@ -617,59 +920,21 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
return modelOptions.filter(matchesModelQuery); return modelOptions.filter(matchesModelQuery);
} }
const concreteOptions = modelOptions return visibleOpenCodeModelMetadata.map((metadata) => metadata.option);
.filter((option) => option.value.trim().length > 0) }, [effectiveProviderId, modelOptions, modelQuery, visibleOpenCodeModelMetadata]);
.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,
]);
const visibleOpenCodeModelGroups = useMemo<OpenCodeModelGroup[]>(() => { const visibleOpenCodeModelGroups = useMemo<OpenCodeModelGroup[]>(() => {
if (effectiveProviderId !== 'opencode') { if (effectiveProviderId !== 'opencode') {
return []; return [];
} }
const groups = new Map<string, OpenCodeModelGroup>(); const groups = new Map<string, OpenCodeModelGroup>();
for (const option of visibleModelOptions) { for (const metadata of visibleOpenCodeModelMetadata) {
const option = metadata.option;
if (!option.value.trim()) { if (!option.value.trim()) {
continue; continue;
} }
const sourceInfo = getOpenCodeSourceInfo(option.value); const sourceInfo = metadata.sourceInfo;
if (!sourceInfo) { if (!sourceInfo) {
continue; continue;
} }
@ -687,12 +952,19 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
} }
return Array.from(groups.values()); return Array.from(groups.values());
}, [effectiveProviderId, visibleModelOptions]); }, [effectiveProviderId, visibleOpenCodeModelMetadata]);
const visibleDefaultModelOptions = visibleModelOptions.filter((option) => !option.value.trim()); const visibleDefaultModelOptions = visibleModelOptions.filter((option) => !option.value.trim());
const visibleConcreteModelOptionCount =
visibleModelOptions.length - visibleDefaultModelOptions.length;
const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).length; const concreteModelOptionCount = modelOptions.filter((option) => option.value.trim()).length;
const shouldShowModelSearch = concreteModelOptionCount > 8; const shouldShowModelSearch = concreteModelOptionCount > 8;
const trimmedModelQuery = modelQuery.trim(); const trimmedModelQuery = modelQuery.trim();
const shouldConstrainModelListHeight = visibleModelOptions.length > 8; 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 renderModelOption = (opt: TeamRuntimeModelOption): React.JSX.Element => {
const modelDisabledReason = getTeamModelUiDisabledReason( const modelDisabledReason = getTeamModelUiDisabledReason(
effectiveProviderId, effectiveProviderId,
@ -706,6 +978,8 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
opt.value !== '' && availabilityStatus === 'unavailable' opt.value !== '' && availabilityStatus === 'unavailable'
? (availabilityReason ?? 'Unavailable in current runtime') ? (availabilityReason ?? 'Unavailable in current runtime')
: null; : null;
const modelAdvisoryReason =
opt.value === '' ? null : (modelAdvisoryReasonByValue?.[opt.value] ?? null);
const modelIssueReason = const modelIssueReason =
opt.value === '' ? null : (modelIssueReasonByValue?.[opt.value] ?? null); opt.value === '' ? null : (modelIssueReasonByValue?.[opt.value] ?? null);
const modelUnavailableReason = const modelUnavailableReason =
@ -718,7 +992,8 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
runtimeProviderStatus runtimeProviderStatus
) ?? ) ??
runtimeUnavailableReason); runtimeUnavailableReason);
const hasModelIssue = Boolean(modelIssueReason || modelUnavailableReason); const hasBlockingModelIssue = Boolean(modelIssueReason || modelUnavailableReason);
const hasModelAdvisory = Boolean(modelAdvisoryReason) && !hasBlockingModelIssue;
const modelSelectable = const modelSelectable =
activeProviderSelectable && activeProviderSelectable &&
!modelUnavailableReason && !modelUnavailableReason &&
@ -727,14 +1002,17 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
const modelStatusMessage = const modelStatusMessage =
modelUnavailableReason ?? modelUnavailableReason ??
modelIssueReason ?? modelIssueReason ??
modelAdvisoryReason ??
modelDisabledReason ?? modelDisabledReason ??
availabilityReason ?? availabilityReason ??
null; 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 = const openCodePricingInfo =
effectiveProviderId === 'opencode' effectiveProviderId === 'opencode' ? (openCodeMetadata?.pricingInfo ?? null) : null;
? getOpenCodeModelPricingInfo(openCodeCatalogModelById.get(opt.value))
: null;
const modelButtonTitle = const modelButtonTitle =
modelStatusMessage ?? (opt.value === '' ? defaultModelTooltip : undefined); modelStatusMessage ?? (opt.value === '' ? defaultModelTooltip : undefined);
@ -747,15 +1025,19 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
title={modelButtonTitle} title={modelButtonTitle}
className={cn( 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', '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' ? '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' ? '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 : hasModelAdvisory && normalizedValue === opt.value
? 'border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm' ? 'border-amber-300/55 bg-amber-300/10 text-amber-100 shadow-sm'
: modelSelectable : hasModelAdvisory
? '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-amber-300/35 bg-amber-300/5 text-amber-200 hover:border-amber-300/55 hover:bg-amber-300/10 hover:text-amber-100'
: 'border-[var(--color-border-subtle)] text-[var(--color-text-muted)]', : 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', !modelSelectable && 'cursor-not-allowed',
!modelDisabledReason && !activeProviderSelectable && 'pointer-events-none' !modelDisabledReason && !activeProviderSelectable && 'pointer-events-none'
)} )}
@ -833,7 +1115,7 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</HoverTooltip> </HoverTooltip>
</span> </span>
) : null} ) : null}
{hasModelIssue && ( {hasBlockingModelIssue ? (
<span <span
className="flex items-center justify-center gap-1 text-[10px] font-normal text-red-300" className="flex items-center justify-center gap-1 text-[10px] font-normal text-red-300"
title={modelStatusMessage ?? undefined} title={modelStatusMessage ?? undefined}
@ -851,8 +1133,27 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</HoverTooltip> </HoverTooltip>
) : null} ) : null}
</span> </span>
)} ) : null}
{!hasModelIssue && modelDisabledReason && ( {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 <span
className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]" className="flex items-center justify-center gap-1 text-[10px] font-normal text-[var(--color-text-muted)]"
title={modelDisabledReason} title={modelDisabledReason}
@ -1069,43 +1370,56 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
</div> </div>
) : null} ) : null}
{effectiveProviderId === 'opencode' ? ( {effectiveProviderId === 'opencode' ? (
<div shouldVirtualizeOpenCodeModels ? (
data-testid="team-model-selector-model-grid" <OpenCodeVirtualizedModelGrid
className={cn( defaultOptions={visibleDefaultModelOptions}
'space-y-3 rounded-md bg-[var(--color-surface)]', groups={visibleOpenCodeModelGroups}
shouldConstrainModelListHeight && 'overflow-y-auto pr-1' renderModelOption={renderModelOption}
)} />
style={{ ) : (
maxHeight: shouldConstrainModelListHeight ? 400 : undefined, <div
}} data-testid="team-model-selector-model-grid"
> className={cn(
{visibleDefaultModelOptions.length > 0 ? ( 'space-y-3 rounded-md bg-[var(--color-surface)]',
<div shouldConstrainModelListHeight && 'overflow-y-auto pr-1'
className="grid gap-1.5" )}
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }} style={{
> maxHeight: shouldConstrainModelListHeight
{visibleDefaultModelOptions.map(renderModelOption)} ? OPENCODE_MODEL_GRID_MAX_HEIGHT_PX
</div> : undefined,
) : null} }}
{visibleOpenCodeModelGroups.map((group) => ( >
<section key={group.sourceId} data-testid="team-model-selector-opencode-group"> {visibleDefaultModelOptions.length > 0 ? (
<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 <div
className="grid gap-1.5" className="grid gap-1.5"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }} style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))' }}
> >
{group.options.map(renderModelOption)} {visibleDefaultModelOptions.map(renderModelOption)}
</div> </div>
</section> ) : null}
))} {visibleOpenCodeModelGroups.map((group) => (
</div> <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 <div
data-testid="team-model-selector-model-grid" data-testid="team-model-selector-model-grid"
@ -1115,7 +1429,9 @@ export const TeamModelSelector: React.FC<TeamModelSelectorProps> = ({
)} )}
style={{ style={{
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
maxHeight: shouldConstrainModelListHeight ? 400 : undefined, maxHeight: shouldConstrainModelListHeight
? OPENCODE_MODEL_GRID_MAX_HEIGHT_PX
: undefined,
}} }}
> >
{visibleModelOptions.map(renderModelOption)} {visibleModelOptions.map(renderModelOption)}

View file

@ -3,6 +3,7 @@ import { getDefaultProviderBackendId } from '@renderer/utils/providerBackendIden
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability'; import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability';
import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import type { ResolvedTeamMember, TeamCreateRequest, TeamProviderId } from '@shared/types'; import type { ResolvedTeamMember, TeamCreateRequest, TeamProviderId } from '@shared/types';
@ -107,14 +108,17 @@ export function resolveLaunchDialogPrefill({
savedRequest?.fastMode ?? previousLaunchParams?.fastMode ?? storedFastMode ?? 'inherit'; savedRequest?.fastMode ?? previousLaunchParams?.fastMode ?? storedFastMode ?? 'inherit';
const limitContext = const limitContext =
previousLaunchParams?.limitContext ?? savedRequest?.limitContext ?? storedLimitContext; previousLaunchParams?.limitContext ?? savedRequest?.limitContext ?? storedLimitContext;
const providerBackendId =
migrateProviderBackendId(
providerId,
previousLaunchParams?.providerBackendId?.trim() || savedRequest?.providerBackendId?.trim()
) ??
getDefaultProviderBackendId(providerId) ??
undefined;
return { return {
providerId, providerId,
providerBackendId: providerBackendId,
previousLaunchParams?.providerBackendId?.trim() ||
savedRequest?.providerBackendId?.trim() ||
getDefaultProviderBackendId(providerId) ||
undefined,
model: matchingModel model: matchingModel
? normalizeExplicitTeamModelForUi(providerId, matchingModel) ? normalizeExplicitTeamModelForUi(providerId, matchingModel)
: getStoredModel(providerId), : getStoredModel(providerId),

View file

@ -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 { function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
} }
@ -315,6 +333,7 @@ function createOpenCodeAdvisoryDeepVerificationModelResult(
): ProviderPrepareDiagnosticsModelResult { ): ProviderPrepareDiagnosticsModelResult {
const line = `${getModelLabel(providerId, modelId)} - ping not confirmed`; const line = `${getModelLabel(providerId, modelId)} - ping not confirmed`;
return { return {
// TODO: Introduce a dedicated `unconfirmed` model result status for deep-ping advisory results.
status: 'notes', status: 'notes',
line, line,
warningLine: line, warningLine: line,

View file

@ -56,11 +56,13 @@ export function getShortLivedProviderPrepareModelIssueReasons({
providerId: TeamProviderId; providerId: TeamProviderId;
cacheKey: string; cacheKey: string;
}): { }): {
modelAdvisoryReasonByValue: Record<string, string>;
modelIssueReasonByValue: Record<string, string>; modelIssueReasonByValue: Record<string, string>;
modelUnavailableReasonByValue: Record<string, string>; modelUnavailableReasonByValue: Record<string, string>;
} { } {
if (providerId !== 'opencode') { if (providerId !== 'opencode') {
return { return {
modelAdvisoryReasonByValue: {},
modelIssueReasonByValue: {}, modelIssueReasonByValue: {},
modelUnavailableReasonByValue: {}, modelUnavailableReasonByValue: {},
}; };
@ -71,11 +73,13 @@ export function getShortLivedProviderPrepareModelIssueReasons({
const entry = shortLivedProviderPrepareIssueCache.get(cacheKey); const entry = shortLivedProviderPrepareIssueCache.get(cacheKey);
if (!entry) { if (!entry) {
return { return {
modelAdvisoryReasonByValue: {},
modelIssueReasonByValue: {}, modelIssueReasonByValue: {},
modelUnavailableReasonByValue: {}, modelUnavailableReasonByValue: {},
}; };
} }
const modelAdvisoryReasonByValue: Record<string, string> = {};
const modelIssueReasonByValue: Record<string, string> = {}; const modelIssueReasonByValue: Record<string, string> = {};
const modelUnavailableReasonByValue: Record<string, string> = {}; const modelUnavailableReasonByValue: Record<string, string> = {};
for (const [modelId, result] of Object.entries(entry.modelResultsById)) { for (const [modelId, result] of Object.entries(entry.modelResultsById)) {
@ -86,11 +90,12 @@ export function getShortLivedProviderPrepareModelIssueReasons({
if (result.status === 'failed') { if (result.status === 'failed') {
modelUnavailableReasonByValue[modelId] = reason; modelUnavailableReasonByValue[modelId] = reason;
} else if (result.status === 'notes') { } else if (result.status === 'notes') {
modelIssueReasonByValue[modelId] = reason; modelAdvisoryReasonByValue[modelId] = reason;
} }
} }
return { return {
modelAdvisoryReasonByValue,
modelIssueReasonByValue, modelIssueReasonByValue,
modelUnavailableReasonByValue, modelUnavailableReasonByValue,
}; };
@ -132,6 +137,22 @@ export function storeShortLivedProviderPrepareModelResults({
} }
if (Object.keys(issueResultsById).length > 0) { 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 existingIssueEntry = shortLivedProviderPrepareIssueCache.get(cacheKey);
const nextIssueResultsById = { const nextIssueResultsById = {
...(existingIssueEntry?.modelResultsById ?? {}), ...(existingIssueEntry?.modelResultsById ?? {}),

View file

@ -46,6 +46,7 @@ interface LeadModelRowProps {
warningText?: string | null; warningText?: string | null;
disableGeminiOption?: boolean; disableGeminiOption?: boolean;
modelIssueText?: string | null; modelIssueText?: string | null;
modelAdvisoryReasonByValue?: Partial<Record<string, string | null | undefined>>;
modelIssueReasonByValue?: Partial<Record<string, string | null | undefined>>; modelIssueReasonByValue?: Partial<Record<string, string | null | undefined>>;
modelUnavailableReasonByValue?: Partial<Record<string, string | null | undefined>>; modelUnavailableReasonByValue?: Partial<Record<string, string | null | undefined>>;
showAnthropicContextLimit?: boolean; showAnthropicContextLimit?: boolean;
@ -66,6 +67,7 @@ export const LeadModelRow = ({
warningText, warningText,
disableGeminiOption = false, disableGeminiOption = false,
modelIssueText, modelIssueText,
modelAdvisoryReasonByValue,
modelIssueReasonByValue, modelIssueReasonByValue,
modelUnavailableReasonByValue, modelUnavailableReasonByValue,
showAnthropicContextLimit = providerId === 'anthropic', showAnthropicContextLimit = providerId === 'anthropic',
@ -86,9 +88,15 @@ export const LeadModelRow = ({
model.trim() && modelUnavailableReasonByValue?.[model.trim()] model.trim() && modelUnavailableReasonByValue?.[model.trim()]
? modelUnavailableReasonByValue[model.trim()] ? modelUnavailableReasonByValue[model.trim()]
: null; : null;
const selectedModelAdvisoryText =
model.trim() && modelAdvisoryReasonByValue?.[model.trim()]
? modelAdvisoryReasonByValue[model.trim()]
: null;
const currentModelIssueText = const currentModelIssueText =
modelIssueText ?? selectedModelUnavailableText ?? selectedModelIssueText ?? null; modelIssueText ?? selectedModelUnavailableText ?? selectedModelIssueText ?? null;
const currentModelAdvisoryText = currentModelIssueText ? null : selectedModelAdvisoryText;
const hasModelIssue = Boolean(currentModelIssueText); const hasModelIssue = Boolean(currentModelIssueText);
const hasModelAdvisory = Boolean(currentModelAdvisoryText);
const showSonnetExtraUsageWarning = const showSonnetExtraUsageWarning =
providerId === 'anthropic' && providerId === 'anthropic' &&
!limitContext && !limitContext &&
@ -155,7 +163,9 @@ export const LeadModelRow = ({
className={cn( className={cn(
'h-8 w-full justify-start gap-1 overflow-hidden text-left', 'h-8 w-full justify-start gap-1 overflow-hidden text-left',
hasModelIssue && 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-label={modelButtonAriaLabel}
onClick={() => setModelExpanded((prev) => !prev)} onClick={() => setModelExpanded((prev) => !prev)}
@ -168,6 +178,7 @@ export const LeadModelRow = ({
<ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" /> <ProviderBrandLogo providerId={providerId} className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate">{modelButtonLabel}</span> <span className="min-w-0 flex-1 truncate">{modelButtonLabel}</span>
{hasModelIssue ? <AlertTriangle className="size-3.5 shrink-0 text-red-300" /> : null} {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> </Button>
</div> </div>
</div> </div>
@ -193,6 +204,7 @@ export const LeadModelRow = ({
onValueChange={onModelChange} onValueChange={onModelChange}
id="lead-model" id="lead-model"
disableGeminiOption={disableGeminiOption} disableGeminiOption={disableGeminiOption}
modelAdvisoryReasonByValue={modelAdvisoryReasonByValue}
modelIssueReasonByValue={{ modelIssueReasonByValue={{
...(modelIssueReasonByValue ?? {}), ...(modelIssueReasonByValue ?? {}),
...(model.trim() && modelIssueText ? { [model.trim()]: modelIssueText } : {}), ...(model.trim() && modelIssueText ? { [model.trim()]: modelIssueText } : {}),

View file

@ -5,6 +5,7 @@ import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice'; import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
@ -47,6 +48,7 @@ import type {
interface MemberCardProps { interface MemberCardProps {
member: ResolvedTeamMember; member: ResolvedTeamMember;
memberColor: string; memberColor: string;
fullBleedSurface?: boolean;
runtimeSummary?: string; runtimeSummary?: string;
runtimeEntry?: TeamAgentRuntimeEntry; runtimeEntry?: TeamAgentRuntimeEntry;
runtimeRunId?: string | null; runtimeRunId?: string | null;
@ -78,6 +80,8 @@ interface MemberCardProps {
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void; 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): { function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
summary: string | undefined; summary: string | undefined;
memory: string | undefined; memory: string | undefined;
@ -113,6 +117,7 @@ function getLaunchFailureLinkLabel(url: string): string {
export const MemberCard = memo(function MemberCard({ export const MemberCard = memo(function MemberCard({
member, member,
memberColor, memberColor,
fullBleedSurface = true,
runtimeSummary, runtimeSummary,
runtimeEntry, runtimeEntry,
runtimeRunId, runtimeRunId,
@ -232,6 +237,8 @@ export const MemberCard = memo(function MemberCard({
spawnLaunchState !== 'failed_to_start' && spawnLaunchState !== 'failed_to_start' &&
!activityTask && !activityTask &&
!runtimeSummary; !runtimeSummary;
const usesLaunchSkeletonSurface = spawnCardClass.includes('member-waiting-shimmer');
const rowSurfaceBleedClass = fullBleedSurface ? MEMBER_ROW_SURFACE_BLEED_CLASS : undefined;
const showLaunchBadge = const showLaunchBadge =
!isRemoved && !isRemoved &&
!runtimeAdvisoryLabel && !runtimeAdvisoryLabel &&
@ -359,10 +366,15 @@ export const MemberCard = memo(function MemberCard({
return ( return (
<div <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 <div
className="group relative cursor-pointer rounded py-1.5" className={cn('group relative cursor-pointer rounded py-1.5', rowSurfaceBleedClass)}
style={undefined} style={undefined}
title={activityTitle} title={activityTitle}
role="button" role="button"

View file

@ -78,6 +78,9 @@ interface MemberDraftRowProps {
infoText?: string | null; infoText?: string | null;
disableGeminiOption?: boolean; disableGeminiOption?: boolean;
modelIssueText?: string | null; modelIssueText?: string | null;
modelAdvisoryReasonByProvider?: Partial<
Record<TeamProviderId, Partial<Record<string, string | null | undefined>>>
>;
modelIssueReasonByProvider?: Partial< modelIssueReasonByProvider?: Partial<
Record<TeamProviderId, Partial<Record<string, string | null | undefined>>> Record<TeamProviderId, Partial<Record<string, string | null | undefined>>>
>; >;
@ -134,6 +137,7 @@ export const MemberDraftRow = ({
infoText, infoText,
disableGeminiOption = false, disableGeminiOption = false,
modelIssueText, modelIssueText,
modelAdvisoryReasonByProvider,
modelIssueReasonByProvider, modelIssueReasonByProvider,
modelUnavailableReasonByProvider, modelUnavailableReasonByProvider,
showWorktreeIsolationControls = false, showWorktreeIsolationControls = false,
@ -251,27 +255,41 @@ export const MemberDraftRow = ({
modelUnavailableReasonByProvider?.[effectiveProviderId]?.[effectiveModelKey] modelUnavailableReasonByProvider?.[effectiveProviderId]?.[effectiveModelKey]
? modelUnavailableReasonByProvider[effectiveProviderId]?.[effectiveModelKey] ? modelUnavailableReasonByProvider[effectiveProviderId]?.[effectiveModelKey]
: null; : null;
const selectedModelAdvisoryText =
effectiveModelKey && modelAdvisoryReasonByProvider?.[effectiveProviderId]?.[effectiveModelKey]
? modelAdvisoryReasonByProvider[effectiveProviderId]?.[effectiveModelKey]
: null;
const currentModelIssueText = const currentModelIssueText =
modelIssueText ?? selectedModelUnavailableText ?? selectedModelIssueText ?? null; modelIssueText ?? selectedModelUnavailableText ?? selectedModelIssueText ?? null;
const currentModelAdvisoryText = currentModelIssueText ? null : selectedModelAdvisoryText;
const hasModelIssue = Boolean(currentModelIssueText); const hasModelIssue = Boolean(currentModelIssueText);
const hasModelAdvisory = Boolean(currentModelAdvisoryText);
const modelButtonDisabled = (lockProviderModel && !canOpenLockedModelPanel) || isRemoved; const modelButtonDisabled = (lockProviderModel && !canOpenLockedModelPanel) || isRemoved;
const modelButtonTitle = const modelButtonTitle =
[currentModelIssueText, modelTooltipText] [currentModelIssueText ?? currentModelAdvisoryText, modelTooltipText]
.filter((message): message is string => Boolean(message)) .filter((message): message is string => Boolean(message))
.join('\n') || undefined; .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 modelHelpDescriptionId = modelTooltipText ? `member-${member.id}-model-help` : undefined;
const modelButtonDescribedBy = const modelButtonDescribedBy =
[modelIssueDescriptionId, modelHelpDescriptionId].filter(Boolean).join(' ') || undefined; [modelIssueDescriptionId, modelHelpDescriptionId].filter(Boolean).join(' ') || undefined;
const modelButtonTooltipContent = const modelButtonTooltipContent =
currentModelIssueText || modelTooltipText ? ( currentModelIssueText || currentModelAdvisoryText || modelTooltipText ? (
<> <>
{currentModelIssueText ? ( {currentModelIssueText ? (
<span className="block text-red-300">{currentModelIssueText}</span> <span className="block text-red-300">{currentModelIssueText}</span>
) : null} ) : null}
{currentModelAdvisoryText ? (
<span className="block text-amber-200">{currentModelAdvisoryText}</span>
) : null}
{modelTooltipText ? ( {modelTooltipText ? (
<span <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} {modelTooltipText}
</span> </span>
@ -386,7 +404,9 @@ export const MemberDraftRow = ({
className={cn( className={cn(
'h-8 w-full justify-start gap-1 overflow-hidden text-left', 'h-8 w-full justify-start gap-1 overflow-hidden text-left',
hasModelIssue && 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-label={modelButtonAriaLabel}
aria-describedby={modelButtonDescribedBy} aria-describedby={modelButtonDescribedBy}
@ -403,6 +423,7 @@ export const MemberDraftRow = ({
{hasModelIssue ? ( {hasModelIssue ? (
<AlertTriangle className="size-3.5 shrink-0 text-red-300" /> <AlertTriangle className="size-3.5 shrink-0 text-red-300" />
) : null} ) : null}
{hasModelAdvisory ? <Info className="size-3.5 shrink-0 text-amber-300" /> : null}
</Button> </Button>
</HoverTooltip> </HoverTooltip>
{modelTooltipText ? ( {modelTooltipText ? (
@ -410,13 +431,20 @@ export const MemberDraftRow = ({
{modelTooltipText} {modelTooltipText}
</span> </span>
) : null} ) : null}
{currentModelIssueText ? ( {currentModelIssueText || currentModelAdvisoryText ? (
<p <p
id={modelIssueDescriptionId} 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" /> {currentModelIssueText ? (
<span>{currentModelIssueText}</span> <AlertTriangle className="mt-0.5 size-3 shrink-0" />
) : (
<Info className="mt-0.5 size-3 shrink-0" />
)}
<span>{currentModelIssueText ?? currentModelAdvisoryText}</span>
</p> </p>
) : null} ) : null}
</div> </div>
@ -585,6 +613,7 @@ export const MemberDraftRow = ({
}} }}
id={`member-${member.id}-model`} id={`member-${member.id}-model`}
disableGeminiOption={disableGeminiOption} disableGeminiOption={disableGeminiOption}
modelAdvisoryReasonByValue={modelAdvisoryReasonByProvider?.[effectiveProviderId]}
modelIssueReasonByValue={{ modelIssueReasonByValue={{
...(modelIssueReasonByProvider?.[effectiveProviderId] ?? {}), ...(modelIssueReasonByProvider?.[effectiveProviderId] ?? {}),
...(effectiveModelKey && modelIssueText ...(effectiveModelKey && modelIssueText

View file

@ -1,5 +1,6 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTheme } from '@renderer/hooks/useTheme';
import { import {
deriveReviewActivityTimerAnchor, deriveReviewActivityTimerAnchor,
deriveWorkActivityTimerAnchor, deriveWorkActivityTimerAnchor,
@ -38,6 +39,7 @@ interface MemberListProps {
memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>; memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>;
runtimeRunId?: string | null; runtimeRunId?: string | null;
isLaunchSettling?: boolean; isLaunchSettling?: boolean;
isRosterLoading?: boolean;
isTeamAlive?: boolean; isTeamAlive?: boolean;
isTeamProvisioning?: boolean; isTeamProvisioning?: boolean;
leadActivity?: LeadActivityState; leadActivity?: LeadActivityState;
@ -321,6 +323,7 @@ function areMemberListPropsEqual(
areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) && areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) &&
prev.runtimeRunId === next.runtimeRunId && prev.runtimeRunId === next.runtimeRunId &&
prev.isLaunchSettling === next.isLaunchSettling && prev.isLaunchSettling === next.isLaunchSettling &&
prev.isRosterLoading === next.isRosterLoading &&
prev.isTeamAlive === next.isTeamAlive && prev.isTeamAlive === next.isTeamAlive &&
prev.isTeamProvisioning === next.isTeamProvisioning && prev.isTeamProvisioning === next.isTeamProvisioning &&
prev.leadActivity === next.leadActivity && prev.leadActivity === next.leadActivity &&
@ -338,6 +341,7 @@ interface MemberCardRowProps {
member: ResolvedTeamMember; member: ResolvedTeamMember;
isRemoved: boolean; isRemoved: boolean;
memberColor: string; memberColor: string;
fullBleedSurface: boolean;
currentTask: TeamTaskWithKanban | null; currentTask: TeamTaskWithKanban | null;
reviewTask: TeamTaskWithKanban | null; reviewTask: TeamTaskWithKanban | null;
currentTaskTimer: MemberActivityTimerAnchor | null; currentTaskTimer: MemberActivityTimerAnchor | null;
@ -371,6 +375,7 @@ const MemberCardRow = memo(function MemberCardRow({
member, member,
isRemoved, isRemoved,
memberColor, memberColor,
fullBleedSurface,
currentTask, currentTask,
reviewTask, reviewTask,
currentTaskTimer, currentTaskTimer,
@ -418,6 +423,7 @@ const MemberCardRow = memo(function MemberCardRow({
<MemberCard <MemberCard
member={member} member={member}
memberColor={memberColor} memberColor={memberColor}
fullBleedSurface={fullBleedSurface}
taskCounts={taskCounts} taskCounts={taskCounts}
isTeamAlive={isTeamAlive} isTeamAlive={isTeamAlive}
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}
@ -466,6 +472,7 @@ const MemberListLoadingSkeleton = ({
expectedTeammateCount?: number; expectedTeammateCount?: number;
}>): React.JSX.Element => { }>): React.JSX.Element => {
const skeletonCount = getMemberLoadingSkeletonCount(expectedTeammateCount); const skeletonCount = getMemberLoadingSkeletonCount(expectedTeammateCount);
const { isLight } = useTheme();
return ( return (
<div <div
@ -477,14 +484,17 @@ const MemberListLoadingSkeleton = ({
{Array.from({ length: skeletonCount }, (_, index) => { {Array.from({ length: skeletonCount }, (_, index) => {
const accent = MEMBER_LOADING_ACCENTS[index] ?? MEMBER_LOADING_ACCENTS[0]; const accent = MEMBER_LOADING_ACCENTS[index] ?? MEMBER_LOADING_ACCENTS[0];
return ( return (
<div key={index} className="flex min-h-[52px] min-w-0 items-center gap-3"> <div key={index} className="flex min-h-[52px] min-w-0 items-center gap-2.5">
<div className="relative size-7 shrink-0"> <div className="relative size-[34px] shrink-0">
<div <div
className="absolute inset-0 rounded-full border-2 bg-[var(--color-surface-raised)]" 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 <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 }} style={{ backgroundColor: accent }}
/> />
</div> </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({ export const MemberList = memo(function MemberList({
teamName = '__unknown_team__', teamName = '__unknown_team__',
members, members,
@ -534,6 +564,7 @@ export const MemberList = memo(function MemberList({
memberRuntimeEntries, memberRuntimeEntries,
runtimeRunId, runtimeRunId,
isLaunchSettling, isLaunchSettling,
isRosterLoading,
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
leadActivity, leadActivity,
@ -724,13 +755,20 @@ export const MemberList = memo(function MemberList({
); );
const expectsTeammates = (expectedTeammateCount ?? 0) > 0; const expectsTeammates = (expectedTeammateCount ?? 0) > 0;
const canStillHydrateExpectedTeammates =
Boolean(isRosterLoading || isTeamProvisioning) ||
(isTeamAlive !== false && Boolean(isLaunchSettling));
const shouldShowExpectedTeammateSkeleton = expectsTeammates && canStillHydrateExpectedTeammates;
const hasOnlyLeadWhileTeammatesLoad = const hasOnlyLeadWhileTeammatesLoad =
expectsTeammates && activeTeammateCount === 0 && removedMembers.length === 0; shouldShowExpectedTeammateSkeleton && activeTeammateCount === 0 && removedMembers.length === 0;
if (members.length === 0 || hasOnlyLeadWhileTeammatesLoad) { if (members.length === 0) {
if (expectsTeammates) { if (shouldShowExpectedTeammateSkeleton) {
return <MemberListLoadingSkeleton expectedTeammateCount={expectedTeammateCount} />; return <MemberListLoadingSkeleton expectedTeammateCount={expectedTeammateCount} />;
} }
if (expectsTeammates) {
return <MemberRosterUnavailableState expectedTeammateCount={expectedTeammateCount} />;
}
return ( return (
<div className="rounded-md border border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]"> <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 ( return (
<div ref={containerRef} className="flex flex-col gap-1"> <div ref={containerRef} className="flex flex-col gap-1">
<div className={gridClass}> <div className={gridClass}>
@ -787,6 +829,7 @@ export const MemberList = memo(function MemberList({
member={member} member={member}
isRemoved={false} isRemoved={false}
memberColor={colorMap.get(member.name) ?? 'blue'} memberColor={colorMap.get(member.name) ?? 'blue'}
fullBleedSurface={!isWide}
currentTask={currentTask} currentTask={currentTask}
reviewTask={reviewTask} reviewTask={reviewTask}
currentTaskTimer={currentTaskTimer} currentTaskTimer={currentTaskTimer}
@ -832,6 +875,7 @@ export const MemberList = memo(function MemberList({
member={member} member={member}
isRemoved={true} isRemoved={true}
memberColor={colorMap.get(member.name) ?? 'blue'} memberColor={colorMap.get(member.name) ?? 'blue'}
fullBleedSurface={!isWide}
currentTask={null} currentTask={null}
reviewTask={null} reviewTask={null}
currentTaskTimer={null} currentTaskTimer={null}

View file

@ -7,6 +7,7 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRole
import { cn } from '@renderer/lib/utils'; import { cn } from '@renderer/lib/utils';
import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog'; import { getParticipantAvatarUrlByIndex } from '@renderer/utils/memberAvatarCatalog';
import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { isTeamEffortLevel } from '@shared/utils/effortLevels';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider'; import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
import { GitBranch, Plus } from 'lucide-react'; import { GitBranch, Plus } from 'lucide-react';
@ -115,6 +116,9 @@ export interface MembersEditorSectionProps {
memberInfoById?: Record<string, string | null | undefined>; memberInfoById?: Record<string, string | null | undefined>;
disableGeminiOption?: boolean; disableGeminiOption?: boolean;
memberModelIssueById?: Record<string, string | null | undefined>; memberModelIssueById?: Record<string, string | null | undefined>;
modelAdvisoryReasonByProvider?: Partial<
Record<TeamProviderId, Partial<Record<string, string | null | undefined>>>
>;
modelIssueReasonByProvider?: Partial< modelIssueReasonByProvider?: Partial<
Record<TeamProviderId, Partial<Record<string, string | null | undefined>>> Record<TeamProviderId, Partial<Record<string, string | null | undefined>>>
>; >;
@ -160,6 +164,7 @@ export const MembersEditorSection = ({
memberInfoById, memberInfoById,
disableGeminiOption = false, disableGeminiOption = false,
memberModelIssueById, memberModelIssueById,
modelAdvisoryReasonByProvider,
modelIssueReasonByProvider, modelIssueReasonByProvider,
modelUnavailableReasonByProvider, modelUnavailableReasonByProvider,
disableAddMember = false, disableAddMember = false,
@ -232,11 +237,18 @@ export const MembersEditorSection = ({
onChange( onChange(
members.map((c) => members.map((c) =>
c.id === memberId c.id === memberId
? { ? (() => {
...c, const previousProviderId = c.providerId ?? inheritedProviderId;
providerId, const providerChanged = previousProviderId !== providerId;
model: c.providerId === providerId ? c.model : '', return {
} ...c,
providerId,
providerBackendId: migrateProviderBackendId(providerId, c.providerBackendId),
model: providerChanged ? '' : c.model,
effort: providerChanged ? undefined : c.effort,
fastMode: providerChanged ? undefined : c.fastMode,
};
})()
: c : c
) )
); );
@ -444,6 +456,7 @@ export const MembersEditorSection = ({
infoText={memberInfoById?.[member.id] ?? null} infoText={memberInfoById?.[member.id] ?? null}
disableGeminiOption={disableGeminiOption} disableGeminiOption={disableGeminiOption}
modelIssueText={memberModelIssueById?.[member.id] ?? null} modelIssueText={memberModelIssueById?.[member.id] ?? null}
modelAdvisoryReasonByProvider={modelAdvisoryReasonByProvider}
modelIssueReasonByProvider={modelIssueReasonByProvider} modelIssueReasonByProvider={modelIssueReasonByProvider}
modelUnavailableReasonByProvider={modelUnavailableReasonByProvider} modelUnavailableReasonByProvider={modelUnavailableReasonByProvider}
/> />

View file

@ -49,6 +49,9 @@ interface TeamRosterEditorSectionProps {
disableGeminiOption?: boolean; disableGeminiOption?: boolean;
leadModelIssueText?: string | null; leadModelIssueText?: string | null;
memberModelIssueById?: Record<string, string | null | undefined>; memberModelIssueById?: Record<string, string | null | undefined>;
modelAdvisoryReasonByProvider?: Partial<
Record<TeamProviderId, Partial<Record<string, string | null | undefined>>>
>;
modelIssueReasonByProvider?: Partial< modelIssueReasonByProvider?: Partial<
Record<TeamProviderId, Partial<Record<string, string | null | undefined>>> Record<TeamProviderId, Partial<Record<string, string | null | undefined>>>
>; >;
@ -101,6 +104,7 @@ const TeamRosterEditorSectionImpl = ({
disableGeminiOption = false, disableGeminiOption = false,
leadModelIssueText, leadModelIssueText,
memberModelIssueById, memberModelIssueById,
modelAdvisoryReasonByProvider,
modelIssueReasonByProvider, modelIssueReasonByProvider,
modelUnavailableReasonByProvider, modelUnavailableReasonByProvider,
showWorktreeIsolationControls = false, showWorktreeIsolationControls = false,
@ -161,6 +165,7 @@ const TeamRosterEditorSectionImpl = ({
softDeleteMembers={softDeleteMembers} softDeleteMembers={softDeleteMembers}
disableGeminiOption={disableGeminiOption} disableGeminiOption={disableGeminiOption}
memberModelIssueById={memberModelIssueById} memberModelIssueById={memberModelIssueById}
modelAdvisoryReasonByProvider={modelAdvisoryReasonByProvider}
modelIssueReasonByProvider={modelIssueReasonByProvider} modelIssueReasonByProvider={modelIssueReasonByProvider}
modelUnavailableReasonByProvider={modelUnavailableReasonByProvider} modelUnavailableReasonByProvider={modelUnavailableReasonByProvider}
showWorktreeIsolationControls={showWorktreeIsolationControls} showWorktreeIsolationControls={showWorktreeIsolationControls}
@ -184,6 +189,7 @@ const TeamRosterEditorSectionImpl = ({
warningText={leadWarningText} warningText={leadWarningText}
disableGeminiOption={disableGeminiOption} disableGeminiOption={disableGeminiOption}
modelIssueText={leadModelIssueText} modelIssueText={leadModelIssueText}
modelAdvisoryReasonByValue={modelAdvisoryReasonByProvider?.[providerId]}
modelIssueReasonByValue={modelIssueReasonByProvider?.[providerId]} modelIssueReasonByValue={modelIssueReasonByProvider?.[providerId]}
modelUnavailableReasonByValue={modelUnavailableReasonByProvider?.[providerId]} modelUnavailableReasonByValue={modelUnavailableReasonByProvider?.[providerId]}
showAnthropicContextLimit={hasAnthropicRuntime} showAnthropicContextLimit={hasAnthropicRuntime}

View file

@ -2,11 +2,15 @@ import { CUSTOM_ROLE, NO_ROLE, PRESET_ROLES } from '@renderer/constants/teamRole
import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { serializeChipsWithText } from '@renderer/types/inlineChip';
import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze'; import { normalizeCreateLaunchProviderForUi } from '@renderer/utils/geminiUiFreeze';
import { normalizeExplicitTeamModelForUi } from '@renderer/utils/teamModelAvailability'; 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 { isLeadMember } from '@shared/utils/leadDetection';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors'; import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
import { validateTeamMemberNameFormat } from '@shared/utils/teamMemberName'; 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 { MemberDraft } from './membersEditorTypes';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
@ -150,6 +154,44 @@ function normalizeDraftEffort(value: string | undefined): EffortLevel | undefine
return isTeamEffortLevel(value) ? value : undefined; 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 { interface ExistingMemberColorInput {
name: string; name: string;
color?: string; color?: string;
@ -248,7 +290,10 @@ export function getWorkflowForExport(member: MemberDraft): string | undefined {
return chips.length > 0 ? serializeChipsWithText(workflowRaw, chips) : workflowRaw; 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 return members
.map((member) => { .map((member) => {
if (member.removedAt) { if (member.removedAt) {
@ -268,14 +313,27 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
if (providerId) { if (providerId) {
result.providerId = providerId; result.providerId = providerId;
} }
if (member.providerBackendId) { const providerBackendId = normalizeDraftProviderBackendForProvider(
result.providerBackendId = member.providerBackendId; member.providerBackendId,
providerId ?? options?.inheritedProviderId
);
if (providerBackendId) {
result.providerBackendId = providerBackendId;
} }
const model = member.model?.trim(); const model = member.model?.trim();
if (model) { 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) { if (effort) {
result.effort = effort; result.effort = effort;
} }

View file

@ -59,6 +59,7 @@ interface MessageComposerProps {
teamName: string; teamName: string;
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
layout?: 'default' | 'compact'; layout?: 'default' | 'compact';
widthMode?: 'full' | 'floating-adaptive';
isTeamAlive?: boolean; isTeamAlive?: boolean;
sending: boolean; sending: boolean;
sendError: string | null; sendError: string | null;
@ -95,6 +96,9 @@ interface PendingSendState {
} }
let pendingSendIdCounter = 0; let pendingSendIdCounter = 0;
const FLOATING_COMPOSER_MIN_WIDTH = 350;
const FLOATING_COMPOSER_MAX_WIDTH = 500;
const FLOATING_COMPOSER_TEXT_BUFFER = 4;
function createPendingSendId(): string { function createPendingSendId(): string {
const randomId = globalThis.crypto?.randomUUID?.(); const randomId = globalThis.crypto?.randomUUID?.();
@ -107,6 +111,7 @@ export const MessageComposer = ({
teamName, teamName,
members, members,
layout = 'default', layout = 'default',
widthMode = 'full',
isTeamAlive, isTeamAlive,
sending, sending,
sendError, sendError,
@ -653,6 +658,68 @@ export const MessageComposer = ({
draft.attachments.length > 0 || Boolean(draft.attachmentError ?? fileRestrictionError); draft.attachments.length > 0 || Boolean(draft.attachmentError ?? fileRestrictionError);
const shouldDockRecipientSelector = !hasAttachmentPreviewContent; const shouldDockRecipientSelector = !hasAttachmentPreviewContent;
const isCompactLayout = layout === 'compact'; 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 ? ( 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"> <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" /> <AlertCircle size={10} className="shrink-0" />
@ -698,6 +765,7 @@ export const MessageComposer = ({
return ( return (
<div <div
className={cn('relative', isCompactLayout ? 'pb-1' : 'mb-1.5 pb-1.5')} className={cn('relative', isCompactLayout ? 'pb-1' : 'mb-1.5 pb-1.5')}
style={floatingAdaptiveStyle}
role="group" role="group"
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}

View file

@ -122,6 +122,8 @@ interface MessagesPanelProps {
onRestartTeam?: () => void; onRestartTeam?: () => void;
/** Callback when a task ID link is clicked. */ /** Callback when a task ID link is clicked. */
onTaskIdClick?: (taskId: string) => void; 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'`. * Scroll container owned by the parent view when `position === 'inline'`.
* MessagesPanel does not own this element the viewport lives in * MessagesPanel does not own this element the viewport lives in
@ -356,6 +358,7 @@ export const MessagesPanel = memo(function MessagesPanel({
onReplyToMessage, onReplyToMessage,
onRestartTeam, onRestartTeam,
onTaskIdClick, onTaskIdClick,
onFloatingComposerHeightChange,
inlineScrollContainerRef, inlineScrollContainerRef,
}: MessagesPanelProps): React.JSX.Element { }: MessagesPanelProps): React.JSX.Element {
const { const {
@ -413,6 +416,7 @@ export const MessagesPanel = memo(function MessagesPanel({
effectiveMessages.length === 0 && (messagesState === undefined || messagesState.loadingHead); effectiveMessages.length === 0 && (messagesState === undefined || messagesState.loadingHead);
const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null); const composerTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const floatingComposerMeasureRef = useRef<HTMLDivElement | null>(null);
const sidebarScrollRef = useRef<HTMLDivElement | null>(null); const sidebarScrollRef = useRef<HTMLDivElement | null>(null);
const bottomSheetRef = useRef<SheetRef>(null); const bottomSheetRef = useRef<SheetRef>(null);
const bottomSheetStickyTopRef = useRef<HTMLDivElement | null>(null); const bottomSheetStickyTopRef = useRef<HTMLDivElement | null>(null);
@ -819,6 +823,30 @@ export const MessagesPanel = memo(function MessagesPanel({
onPositionChange('floating-composer'); onPositionChange('floating-composer');
}, [onPositionChange]); }, [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) => { const snapBottomSheetTo = useCallback((snapIndex: number) => {
setBottomSheetSnapIndex(snapIndex); setBottomSheetSnapIndex(snapIndex);
bottomSheetRef.current?.snapTo(snapIndex); bottomSheetRef.current?.snapTo(snapIndex);
@ -877,49 +905,38 @@ export const MessagesPanel = memo(function MessagesPanel({
); );
const floatingComposerModeControls = ( const floatingComposerModeControls = (
<div className="inline-flex items-center gap-0.5 pr-1"> <div className="inline-flex items-center pr-1">
<Tooltip> <DropdownMenu>
<TooltipTrigger asChild> <Tooltip>
<Button <TooltipTrigger asChild>
variant="ghost" <DropdownMenuTrigger asChild>
size="sm" <Button
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]" variant="ghost"
onClick={moveToInline} size="sm"
aria-label="Move messages to inline panel" 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"
<PanelBottom size={13} /> >
</Button> <MoreHorizontal size={14} />
</TooltipTrigger> </Button>
<TooltipContent side="top">Move to inline</TooltipContent> </DropdownMenuTrigger>
</Tooltip> </TooltipTrigger>
<Tooltip> <TooltipContent side="top">Message panel mode</TooltipContent>
<TooltipTrigger asChild> </Tooltip>
<Button <DropdownMenuContent align="end" side="top" className="w-48">
variant="ghost" <DropdownMenuItem onSelect={moveToInline}>
size="sm" <PanelBottom size={14} className="shrink-0" />
className="size-6 p-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]" <span>Move to inline</span>
onClick={moveToBottomSheet} </DropdownMenuItem>
aria-label="Move messages to bottom sheet" <DropdownMenuItem onSelect={moveToBottomSheet}>
> <PanelBottomOpen size={14} className="shrink-0" />
<PanelBottomOpen size={13} /> <span>Move to bottom sheet</span>
</Button> </DropdownMenuItem>
</TooltipTrigger> <DropdownMenuItem onSelect={moveToSidebar}>
<TooltipContent side="top">Move to bottom sheet</TooltipContent> <PanelLeft size={14} className="shrink-0" />
</Tooltip> <span>Move to sidebar</span>
<Tooltip> </DropdownMenuItem>
<TooltipTrigger asChild> </DropdownMenuContent>
<Button </DropdownMenu>
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> </div>
); );
@ -944,6 +961,7 @@ export const MessagesPanel = memo(function MessagesPanel({
<MessagesComposerSection <MessagesComposerSection
teamName={teamName} teamName={teamName}
layout="compact" layout="compact"
widthMode="floating-adaptive"
members={members} members={members}
isTeamAlive={isTeamAlive} isTeamAlive={isTeamAlive}
sending={sendingMessage} sending={sendingMessage}
@ -1205,8 +1223,10 @@ export const MessagesPanel = memo(function MessagesPanel({
if (position === 'floating-composer') { if (position === 'floating-composer') {
return ( 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="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="mx-auto flex w-full max-w-[500px] justify-center">
<div className="pointer-events-auto">{floatingComposerSection}</div> <div ref={floatingComposerMeasureRef} className="pointer-events-auto">
{floatingComposerSection}
</div>
</div> </div>
</div> </div>
); );

View file

@ -377,7 +377,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false }); const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null); const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null);
const [pendingNewCount, setPendingNewCount] = useState(0); const [pendingNewCount, setPendingNewCount] = useState(0);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -415,6 +415,7 @@ export function useClaudeLogsController(teamName: string): ClaudeLogsController
setData({ lines: [], total: 0, hasMore: false }); setData({ lines: [], total: 0, hasMore: false });
setPending(null); setPending(null);
setPendingNewCount(0); setPendingNewCount(0);
setLoading(true);
latestRef.current = null; latestRef.current = null;
atTopRef.current = true; atTopRef.current = true;
setError(null); setError(null);

View file

@ -4,6 +4,7 @@ import { api } from '@renderer/api';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence'; import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability'; import { classifyTaskChangeReviewability } from '@shared/utils/taskChangeReviewability';
import { getTaskChangeStateBucket } from '@shared/utils/taskChangeState';
import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout'; import { withTeamChangesLoadTimeout } from './teamChangesLoadTimeout';
import { import {
@ -169,6 +170,34 @@ function resolveCacheablePresenceFromChangeSet(
return null; 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 { function isCountableTeamChangeSummary(item: TeamTaskChangeSummaryItem): boolean {
if (item.error) { if (item.error) {
return true; return true;
@ -417,9 +446,16 @@ export function useTeamChangesSummaries({
autoRefreshBlockedUntilRef.current = 0; autoRefreshBlockedUntilRef.current = 0;
const responseItems = getSafeResponseItems(response); 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) => { setChangeCountByTaskId((previous) => {
const next: Record<string, boolean> = {}; const next: Record<string, boolean> = {};
const currentTaskIds = new Set(tasks.map((task) => task.id));
for (const [taskId, countable] of Object.entries(previous)) { for (const [taskId, countable] of Object.entries(previous)) {
if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) { if (currentTaskIds.has(taskId) && plan.eligibleTaskIds.has(taskId)) {
next[taskId] = countable; next[taskId] = countable;
@ -433,14 +469,23 @@ export function useTeamChangesSummaries({
}); });
setCounterLoaded(true); setCounterLoaded(true);
const currentTaskIds = new Set(tasks.map((task) => task.id));
for (const item of responseItems) { for (const item of responseItems) {
const changeSet = item.changeSet; const changeSet = item.changeSet;
const options = plan.requestOptionsByTaskId.get(item.taskId); const options = plan.requestOptionsByTaskId.get(item.taskId);
if (!changeSet || !options) continue; if (!changeSet || !options) continue;
const nextPresence = resolveCacheablePresenceFromChangeSet(changeSet); 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); recordTaskChangePresence(teamName, item.taskId, options, nextPresence);
setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence); setSelectedTeamTaskChangePresence(teamName, item.taskId, nextPresence);
} }

View file

@ -20,6 +20,7 @@ import { DEFAULT_TEAM_GRAPH_LAYOUT_MODE } from '@shared/constants/teamGraphLayou
import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team';
import { isLeadMember } from '@shared/utils/leadDetection'; import { isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout'; import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId'; import { getStableTeamOwnerId } from '@shared/utils/teamStableOwnerId';
@ -3078,6 +3079,69 @@ function extractBaseModel(raw?: string, providerId?: TeamProviderId): string | u
return extractProviderScopedBaseModel(raw, providerId); 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:'; const TOOL_APPROVAL_PREFIX = 'team:toolApprovalSettings:';
function parseToolApprovalSettings(raw: string | null): 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 // Initialize per-team tool approval settings based on skipPermissions flag
const initialSettings: ToolApprovalSettings = const initialSettings: ToolApprovalSettings =
request.skipPermissions === false request.skipPermissions === false
@ -5487,21 +5559,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
} }
const response = await unwrapIpc('team:create', () => api.teams.createTeam(request)); const response = await unwrapIpc('team:create', () => api.teams.createTeam(request));
// Persist per-team launch params (model, effort, limit context) saveLaunchParams(request.teamName, optimisticLaunchParams);
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);
set((state) => ({ set((state) => ({
launchParamsByTeam: { launchParamsByTeam: {
...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) { if (nextCurrentRunIdByTeam[request.teamName] === pendingRunId) {
delete nextCurrentRunIdByTeam[request.teamName]; 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 { return {
provisioningRuns: nextRuns, provisioningRuns: nextRuns,
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam, currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
launchParamsByTeam: nextLaunchParamsByTeam,
provisioningErrorByTeam: { provisioningErrorByTeam: {
...state.provisioningErrorByTeam, ...state.provisioningErrorByTeam,
[request.teamName]: message, [request.teamName]: message,
@ -5648,6 +5721,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
[request.teamName]: pendingRunId, [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 // Initialize per-team tool approval settings based on skipPermissions flag
{ {
const launchSettings: ToolApprovalSettings = const launchSettings: ToolApprovalSettings =
@ -5660,21 +5744,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
try { try {
const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request)); const response = await unwrapIpc('team:launch', () => api.teams.launchTeam(request));
// Persist per-team launch params (model, effort, limit context) saveLaunchParams(request.teamName, optimisticLaunchParams);
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);
set((state) => ({ set((state) => ({
launchParamsByTeam: { launchParamsByTeam: {
...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) { if (nextCurrentRunIdByTeam[request.teamName] === pendingRunId) {
delete nextCurrentRunIdByTeam[request.teamName]; 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 { return {
provisioningRuns: nextRuns, provisioningRuns: nextRuns,
currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam, currentProvisioningRunIdByTeam: nextCurrentRunIdByTeam,
launchParamsByTeam: nextLaunchParamsByTeam,
provisioningErrorByTeam: { provisioningErrorByTeam: {
...state.provisioningErrorByTeam, ...state.provisioningErrorByTeam,
[request.teamName]: message, [request.teamName]: message,

View file

@ -1,6 +1,8 @@
import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector'; import { formatTeamModelSummary } from '@renderer/components/team/dialogs/TeamModelSelector';
import { formatBytes } from '@renderer/utils/formatters'; import { formatBytes } from '@renderer/utils/formatters';
import { formatTeamProviderBackendLabel } from '@renderer/utils/providerBackendIdentity'; 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 { inferTeamProviderIdFromModel } from '@shared/utils/teamProvider';
import type { TeamLaunchParams } from '@renderer/store/slices/teamSlice'; 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( export function getRuntimeMemorySourceLabel(
runtimeEntry: TeamAgentRuntimeEntry | undefined runtimeEntry: TeamAgentRuntimeEntry | undefined
): string | undefined { ): string | undefined {
@ -106,48 +118,85 @@ export function resolveMemberRuntimeSummary(
spawnEntry: MemberSpawnStatusEntry | undefined, spawnEntry: MemberSpawnStatusEntry | undefined,
runtimeEntry?: TeamAgentRuntimeEntry runtimeEntry?: TeamAgentRuntimeEntry
): string | undefined { ): string | undefined {
const leadLaunchParams = isLeadMember(member) ? launchParams : undefined;
const memberProviderBackendId = (member as ResolvedTeamMember & { providerBackendId?: string }) const memberProviderBackendId = (member as ResolvedTeamMember & { providerBackendId?: string })
.providerBackendId; .providerBackendId;
const memberModel = member.model?.trim() || ''; const memberModel = member.model?.trim() || '';
const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim(); const runtimeModel = spawnEntry?.runtimeModel?.trim() || runtimeEntry?.runtimeModel?.trim();
const inferredMemberProvider = const runtimeModelProvider = inferTeamProviderIdFromModel(runtimeModel);
inferTeamProviderIdFromModel(memberModel) ?? 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 = const configuredProvider: TeamProviderId =
member.providerId ?? inferredMemberProvider ?? launchParams?.providerId ?? 'anthropic'; authoritativeLaunchParams?.providerId ??
const memberProviderForInheritance = member.providerId ?? inferredMemberProvider; member.providerId ??
inferredMemberProvider ??
launchParams?.providerId ??
'anthropic';
const memberProviderForInheritance =
authoritativeLaunchParams?.providerId ?? member.providerId ?? inferredMemberProvider;
const inheritsLeadRuntimeDefaults = const inheritsLeadRuntimeDefaults =
memberProviderForInheritance == null || memberProviderForInheritance == null ||
launchParams?.providerId == null || launchParams?.providerId == null ||
memberProviderForInheritance === launchParams.providerId; memberProviderForInheritance === launchParams.providerId;
const configuredModel = const configuredModel = authoritativeLaunchParams
memberModel || (inheritsLeadRuntimeDefaults ? launchParams?.model?.trim() || '' : ''); ? authoritativeLaunchParams.model?.trim() || ''
const configuredEffort = : memberModel || (inheritsLeadRuntimeDefaults ? launchParams?.model?.trim() || '' : '');
member.effort ?? (inheritsLeadRuntimeDefaults ? launchParams?.effort : undefined); const configuredEffort = authoritativeLaunchParams
const configuredProviderBackendId = ? authoritativeLaunchParams.effort
memberProviderBackendId ?? : (member.effort ?? (inheritsLeadRuntimeDefaults ? launchParams?.effort : undefined));
(inheritsLeadRuntimeDefaults ? launchParams?.providerBackendId : 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( const backendLabel = normalizeMemberBackendLabel(
configuredProvider, configuredProvider,
formatTeamProviderBackendLabel(configuredProvider, configuredProviderBackendId) formatTeamProviderBackendLabel(configuredProvider, configuredProviderBackendId)
); );
const memorySuffix = shouldShowRuntimeMemory(spawnEntry, runtimeEntry) const memorySuffix =
? ` · ${formatBytes(runtimeEntry!.rssBytes!)}` !runtimeConflictsWithAuthoritativeLaunch && shouldShowRuntimeMemory(spawnEntry, runtimeEntry)
: ''; ? ` · ${formatBytes(runtimeEntry!.rssBytes!)}`
: '';
if (runtimeModel && (isMemberLaunchPending(spawnEntry) || configuredModel.length === 0)) { if (displayRuntimeModel && (launchPending || configuredModel.length === 0)) {
const runtimeProvider = inferTeamProviderIdFromModel(runtimeModel) ?? configuredProvider; const runtimeProvider = runtimeModelProvider ?? configuredProvider;
const summary = formatTeamModelSummary(runtimeProvider, runtimeModel, configuredEffort); const summary = formatTeamModelSummary(runtimeProvider, displayRuntimeModel, configuredEffort);
return `${summary}${backendLabel ? ` · ${backendLabel}` : ''}${memorySuffix}`; return appendRuntimeSummarySuffixes(summary, backendLabel, memorySuffix);
} }
if (isMemberLaunchPending(spawnEntry)) { if (launchPending) {
if (!configuredModel.length && !memorySuffix) { if (!authoritativeLaunchParams && !configuredModel.length && !memorySuffix) {
return undefined; return undefined;
} }
const summary = formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort); const summary = formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort);
return `${summary}${backendLabel ? ` · ${backendLabel}` : ''}${memorySuffix}`; return appendRuntimeSummarySuffixes(summary, backendLabel, memorySuffix);
} }
const summary = formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort); const summary = formatTeamModelSummary(configuredProvider, configuredModel, configuredEffort);
return `${summary}${backendLabel ? ` · ${backendLabel}` : ''}${memorySuffix}`; return appendRuntimeSummarySuffixes(summary, backendLabel, memorySuffix);
} }

View file

@ -1269,7 +1269,7 @@ export function getOpenCodeTeamModelRecommendation(
if (unavailableReason) { if (unavailableReason) {
return { return {
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
reason: unavailableReason, reason: unavailableReason,
}; };
} }

View file

@ -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( function getRuntimeModelAvailability(
providerId: SupportedProviderId, providerId: SupportedProviderId,
model: string, model: string,
@ -411,8 +427,10 @@ export function getAvailableTeamProviderModels(
return []; return [];
} }
return getVisibleRuntimeModels(providerId, providerStatus).filter( const visibleModels = getVisibleRuntimeModels(providerId, providerStatus);
(model) => getRuntimeModelAvailability(providerId, model, providerStatus) === 'available' const modelAvailabilityById = getModelAvailabilityMap(providerStatus);
return visibleModels.filter(
(model) => modelAvailabilityById.get(model)?.status !== 'unavailable'
); );
} }
@ -447,6 +465,17 @@ export function getAvailableTeamProviderModelOptions(
getRuntimeSelectorModels(providerId, providerStatus), getRuntimeSelectorModels(providerId, providerStatus),
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 [ return [
{ value: '', label: 'Default', badgeLabel: 'Default' }, { value: '', label: 'Default', badgeLabel: 'Default' },
...visibleModels.map((model) => { ...visibleModels.map((model) => {
@ -454,8 +483,8 @@ export function getAvailableTeamProviderModelOptions(
if (catalogOption) { if (catalogOption) {
return { return {
...catalogOption, ...catalogOption,
availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus), availabilityStatus: getPrecomputedAvailability(model),
availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus), availabilityReason: getPrecomputedAvailabilityReason(model),
}; };
} }
return { return {
@ -465,8 +494,8 @@ export function getAvailableTeamProviderModelOptions(
providerId === 'opencode' providerId === 'opencode'
? (getTeamModelSourceBadgeLabel(providerId, model) ?? undefined) ? (getTeamModelSourceBadgeLabel(providerId, model) ?? undefined)
: undefined, : undefined,
availabilityStatus: getRuntimeModelAvailability(providerId, model, providerStatus), availabilityStatus: getPrecomputedAvailability(model),
availabilityReason: getRuntimeModelAvailabilityReason(model, providerStatus), availabilityReason: getPrecomputedAvailabilityReason(model),
}; };
}), }),
]; ];
@ -591,7 +620,8 @@ export function getTeamModelSelectionError(
const availability = getRuntimeModelAvailability(providerId, trimmed, providerStatus); const availability = getRuntimeModelAvailability(providerId, trimmed, providerStatus);
if (availability !== 'available') { if (availability !== 'available') {
const reason = getRuntimeModelAvailabilityReason(trimmed, providerStatus); 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; return null;

View file

@ -84,6 +84,10 @@ export function calculateMessageCost(
cacheReadTokens: number, cacheReadTokens: number,
cacheCreationTokens: number cacheCreationTokens: number
): number { ): number {
if (modelName === '<synthetic>') {
return 0;
}
const pricing = getPricing(modelName); const pricing = getPricing(modelName);
if (!pricing) { if (!pricing) {
if (inputTokens > 0 || outputTokens > 0 || cacheReadTokens > 0 || cacheCreationTokens > 0) { if (inputTokens > 0 || outputTokens > 0 || cacheReadTokens > 0 || cacheCreationTokens > 0) {

View file

@ -6,7 +6,10 @@ const TEAM_PROVIDER_BACKEND_IDS = new Set<TeamProviderBackendId>([
'api', 'api',
'cli-sdk', 'cli-sdk',
'codex-native', '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 { function normalizeOptionalBackendId(value: unknown): string | undefined {
if (typeof value !== 'string') { if (typeof value !== 'string') {
@ -46,15 +49,31 @@ export function migrateProviderBackendId(
providerBackendId: string | null | undefined providerBackendId: string | null | undefined
): TeamProviderBackendId | undefined { ): TeamProviderBackendId | undefined {
const normalizedBackendId = normalizeOptionalBackendId(providerBackendId); const normalizedBackendId = normalizeOptionalBackendId(providerBackendId);
if (providerId !== 'codex') { if (providerId === undefined || providerId === 'anthropic') {
return isTeamProviderBackendId(normalizedBackendId) ? normalizedBackendId : undefined; return undefined;
} }
if (!normalizedBackendId || isLegacyCodexProviderBackendId(normalizedBackendId)) { if (providerId === 'codex') {
return 'codex-native'; 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( export function formatProviderBackendLabel(

View file

@ -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 () => { it('routes draft team launch through createTeam with saved metadata', async () => {
const { app, createTeam, getSavedRequest, launchTeam } = await createApp(); const { app, createTeam, getSavedRequest, launchTeam } = await createApp();
getSavedRequest.mockResolvedValue({ 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 () => { it('returns saved metadata for draft team get without requiring config.json', async () => {
const { app, getSavedRequest, getTeamData } = await createApp(); const { app, getSavedRequest, getTeamData } = await createApp();
getSavedRequest.mockResolvedValue({ getSavedRequest.mockResolvedValue({

View file

@ -14,6 +14,7 @@ import type {
SendMessageResult, SendMessageResult,
TeamViewSnapshot, TeamViewSnapshot,
TeamCreateRequest, TeamCreateRequest,
TeamLaunchRequest,
TeamProviderId, TeamProviderId,
TeamProvisioningProgress, TeamProvisioningProgress,
} from '@shared/types/team'; } 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 () => { it('handleCreateConfig accepts members: []', async () => {
const handler = handlers.get(TEAM_CREATE_CONFIG)!; const handler = handlers.get(TEAM_CREATE_CONFIG)!;
const result = (await handler({} as never, { 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 () => { it('launches draft team through saved request without dropping Electron draft metadata', async () => {
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-launch-')); const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-launch-'));
setClaudeBasePathOverride(claudeRoot); 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 () => { it('handleReplaceMembers accepts members: []', async () => {
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!; const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
const result = (await handler({} as never, 'my-team', { const result = (await handler({} as never, 'my-team', {

View file

@ -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', () => { it('should group consecutive assistant messages into one AIChunk', () => {
const messages = [ const messages = [
createMessage({ createMessage({
@ -273,7 +299,7 @@ describe('ChunkBuilder', () => {
expect(chunks).toHaveLength(0); expect(chunks).toHaveLength(0);
}); });
it('should filter out synthetic assistant messages', () => { it('should filter out empty synthetic assistant messages', () => {
const messages = [ const messages = [
createMessage({ createMessage({
type: 'assistant', type: 'assistant',
@ -286,6 +312,33 @@ describe('ChunkBuilder', () => {
expect(chunks).toHaveLength(0); 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', () => { it('should filter out caveat messages', () => {
const messages = [ const messages = [
createMessage({ createMessage({

View 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>');
}

View file

@ -20,7 +20,12 @@ const SUMMARY_OPTIONS = {
summaryOnly: true, summaryOnly: true,
}; };
function buildAssistantWriteEntry(toolUseId: string, filePath: string, content: string, timestamp: string) { function buildAssistantWriteEntry(
toolUseId: string,
filePath: string,
content: string,
timestamp: string
) {
return { return {
timestamp, timestamp,
type: 'assistant', type: 'assistant',
@ -39,7 +44,11 @@ function buildAssistantWriteEntry(toolUseId: string, filePath: string, content:
} }
async function writeJsonl(filePath: string, entries: object[]): Promise<void> { 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( async function writeTaskFile(
@ -57,7 +66,9 @@ async function writeTaskFile(
status: 'completed', status: 'completed',
createdAt: '2026-03-01T09:55:00.000Z', createdAt: '2026-03-01T09:55:00.000Z',
updatedAt: '2026-03-01T10:10: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: [], historyEvents: [],
...overrides, ...overrides,
}, },
@ -268,7 +279,12 @@ async function writeOpenCodeLedgerEventJournal(
} }
function persistedEntryPath(baseDir: string): string { 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>() { function deferred<T>() {
@ -347,12 +363,11 @@ function makeTaskChangeResult(
endTimestamp: overrides.scope?.endTimestamp ?? '', endTimestamp: overrides.scope?.endTimestamp ?? '',
toolUseIds: overrides.scope?.toolUseIds ?? [], toolUseIds: overrides.scope?.toolUseIds ?? [],
filePaths: overrides.scope?.filePaths ?? files.map((file) => file.filePath), filePaths: overrides.scope?.filePaths ?? files.map((file) => file.filePath),
confidence: confidence: overrides.scope?.confidence ?? {
overrides.scope?.confidence ?? { tier: confidenceTierByLabel[confidence],
tier: confidenceTierByLabel[confidence], label: confidence,
label: confidence, reason: 'test fixture',
reason: 'test fixture', },
},
}, },
warnings: overrides.warning ? [overrides.warning] : [], warnings: overrides.warning ? [overrides.warning] : [],
}; };
@ -367,14 +382,20 @@ function pendingTaskChangeResult(): Promise<ReturnType<typeof makeTaskChangeResu
function createService(params: { function createService(params: {
logPaths: string[]; logPaths: string[];
projectPath?: string; projectPath?: string;
findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>; findLogFileRefsForTask?: (
teamName: string,
taskId: string,
options?: unknown
) => Promise<unknown[]>;
taskChangePresenceRepository?: { taskChangePresenceRepository?: {
upsertEntry: ReturnType<typeof vi.fn>; upsertEntry: ReturnType<typeof vi.fn>;
deleteEntry?: ReturnType<typeof vi.fn>; deleteEntry?: ReturnType<typeof vi.fn>;
}; };
teamLogSourceTracker?: { teamLogSourceTracker?: {
ensureTracking: ReturnType< ensureTracking: ReturnType<
typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>> typeof vi.fn<
() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>
>
>; >;
}; };
taskChangeWorkerClient?: { taskChangeWorkerClient?: {
@ -588,15 +609,24 @@ describe('ChangeExtractorService', () => {
const aliceLogPath = path.join(tmpDir, 'alice.jsonl'); const aliceLogPath = path.join(tmpDir, 'alice.jsonl');
await writeJsonl(aliceLogPath, [ 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) => const findLogFileRefsForTask = vi.fn(
options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : [] async (_teamName: string, _taskId: string, options?: any) =>
options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : []
); );
const service = createService({ logPaths: [aliceLogPath], findLogFileRefsForTask }).service; 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, { const populated = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
owner: 'alice', owner: 'alice',
status: 'completed', status: 'completed',
@ -613,7 +643,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-summary.jsonl'); const logPath = path.join(tmpDir, 'alice-summary.jsonl');
await writeJsonl(logPath, [ 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] }); const { service, findLogFileRefsForTask } = createService({ logPaths: [logPath] });
@ -641,7 +676,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-restart.jsonl'); const logPath = path.join(tmpDir, 'alice-restart.jsonl');
await writeJsonl(logPath, [ 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] }); const first = createService({ logPaths: [logPath] });
@ -657,6 +697,41 @@ describe('ChangeExtractorService', () => {
expect((second.findLogFileRefsForTask as any).mock.calls.length).toBeLessThanOrEqual(1); 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 () => { it('forceFresh overwrites the persisted terminal summary snapshot', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);
@ -664,15 +739,30 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-refresh.jsonl'); const logPath = path.join(tmpDir, 'alice-refresh.jsonl');
await writeJsonl(logPath, [ 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] }); const { service } = createService({ logPaths: [logPath] });
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS); await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
await writeJsonl(logPath, [ await writeJsonl(logPath, [
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 2;\n', '2026-03-01T10:00:00.000Z'), buildAssistantWriteEntry(
buildAssistantWriteEntry('tool-2', '/repo/src/extra.ts', 'export const extra = true;\n', '2026-03-01T10:02:00.000Z'), '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, { const refreshed = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
@ -696,7 +786,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-review.jsonl'); const logPath = path.join(tmpDir, 'alice-review.jsonl');
await writeJsonl(logPath, [ 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] }); const { service } = createService({ logPaths: [logPath] });
@ -729,7 +824,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-project-drift.jsonl'); const logPath = path.join(tmpDir, 'alice-project-drift.jsonl');
await writeJsonl(logPath, [ 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( await createService({ logPaths: [logPath], projectPath: '/repo-a' }).service.getTaskChanges(
@ -738,11 +838,7 @@ describe('ChangeExtractorService', () => {
SUMMARY_OPTIONS SUMMARY_OPTIONS
); );
const drifted = createService({ logPaths: [logPath], projectPath: '/repo-b' }); const drifted = createService({ logPaths: [logPath], projectPath: '/repo-b' });
await drifted.service.getTaskChanges( await drifted.service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
TEAM_NAME,
TASK_ID,
SUMMARY_OPTIONS
);
expect((drifted.findLogFileRefsForTask as any).mock.calls.length).toBeGreaterThan(1); 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'); const logPath = path.join(tmpDir, 'alice-missing-task.jsonl');
await writeJsonl(logPath, [ 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 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' }); await expect(fs.stat(persistedEntryPath(tmpDir))).rejects.toMatchObject({ code: 'ENOENT' });
}); });
@ -771,10 +880,19 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-corrupt.jsonl'); const logPath = path.join(tmpDir, 'alice-corrupt.jsonl');
await writeJsonl(logPath, [ 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(() => {}); vi.spyOn(console, 'warn').mockImplementation(() => {});
await fs.writeFile(persistedEntryPath(tmpDir), '{bad-json', 'utf8'); await fs.writeFile(persistedEntryPath(tmpDir), '{bad-json', 'utf8');
@ -794,7 +912,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-fallback.jsonl'); const logPath = path.join(tmpDir, 'alice-fallback.jsonl');
await writeJsonl(logPath, [ 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( const service = new ChangeExtractorService(
@ -833,10 +956,20 @@ describe('ChangeExtractorService', () => {
const firstLogPath = path.join(tmpDir, 'first.jsonl'); const firstLogPath = path.join(tmpDir, 'first.jsonl');
const secondLogPath = path.join(tmpDir, 'second.jsonl'); const secondLogPath = path.join(tmpDir, 'second.jsonl');
await writeJsonl(firstLogPath, [ 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, [ 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({ const service = createService({
@ -886,7 +1019,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl'); const logPath = path.join(tmpDir, 'alice-inline-unavailable.jsonl');
await writeJsonl(logPath, [ 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(); const computeTaskChanges = vi.fn();
@ -916,7 +1054,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl'); const logPath = path.join(tmpDir, 'alice-inline-worker-error.jsonl');
await writeJsonl(logPath, [ 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 () => { const computeTaskChanges = vi.fn(async () => {
@ -947,7 +1090,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl'); const logPath = path.join(tmpDir, 'alice-worker-summary-cache.jsonl');
await writeJsonl(logPath, [ 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()); const computeTaskChanges = vi.fn(async () => makeTaskChangeResult());
@ -972,7 +1120,12 @@ describe('ChangeExtractorService', () => {
const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl'); const logPath = path.join(tmpDir, 'alice-worker-persisted.jsonl');
await writeJsonl(logPath, [ 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 = { const firstWorker = {
@ -1249,7 +1402,9 @@ describe('ChangeExtractorService', () => {
})); }));
const workerClient = { const workerClient = {
isAvailable: vi.fn(() => true), 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({ const { service } = createService({
logPaths: [], logPaths: [],
@ -1265,6 +1420,199 @@ describe('ChangeExtractorService', () => {
expect(upsertEntry).not.toHaveBeenCalled(); 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 () => { it('runs OpenCode recovery when a ledger result only contains warning notices', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir); setClaudeBasePathOverride(tmpDir);

View file

@ -381,6 +381,33 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
expect(launchArg?.members[0]?.prompt).not.toContain('Join team "team-a"'); 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 () => { it('does not mark the lane clean_success when ready bridge data omits an expected member', async () => {
const launchOpenCodeTeam = vi.fn( const launchOpenCodeTeam = vi.fn(
async () => 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( function bridgePort(
readinessResult: OpenCodeTeamLaunchReadiness, readinessResult: OpenCodeTeamLaunchReadiness,
overrides: Partial<OpenCodeTeamRuntimeBridgePort> = {} overrides: Partial<OpenCodeTeamRuntimeBridgePort> = {}

View file

@ -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', () => { describe('TaskChangeComputer', () => {
let tmpDir: string | null = null; 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 () => { it('shares concurrent JSONL parsing and invalidates when the file changes', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-')); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-change-computer-'));
const logPath = path.join(tmpDir, 'agent.jsonl'); const logPath = path.join(tmpDir, 'agent.jsonl');
@ -452,7 +548,11 @@ describe('TaskChangeComputer', () => {
const logPath = path.join(tmpDir, 'agent.jsonl'); const logPath = path.join(tmpDir, 'agent.jsonl');
await writeJsonl(logPath, [ await writeJsonl(logPath, [
metadataOnlyMultiFileEditToolUse('tool-1', ['/repo/index.html', '/repo/style.css']), 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 = { const logsFinder = {

View file

@ -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 () => { it('refreshes runtime snapshot cache after same-team Anthropic and Gemini mixed relaunch', async () => {
const teamName = 'mixed-anthropic-gemini-runtime-cache-relaunch-safe-e2e'; const teamName = 'mixed-anthropic-gemini-runtime-cache-relaunch-safe-e2e';
await writeMixedTeamConfig({ await writeMixedTeamConfig({

View file

@ -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 () => { it('degrades advisory lookup failure to warning and still completes the snapshot', async () => {
const harness = createGetTeamDataHarness({ const harness = createGetTeamDataHarness({
resolveMembers: () => [buildResolvedMember('alice')], resolveMembers: () => [buildResolvedMember('alice')],

View file

@ -86,7 +86,10 @@ function callWorker(
}); });
} }
async function callListTeams(worker: Worker, teamsDir: string): Promise<{ async function callListTeams(
worker: Worker,
teamsDir: string
): Promise<{
teams: unknown[]; teams: unknown[];
diag?: Record<string, 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[]; tasks: unknown[];
diag?: Record<string, 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 () => { it('prewarms and reuses unchanged team summaries by fingerprint', async () => {
const workerPath = await getWorkerPath(); const workerPath = await getWorkerPath();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-')); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-fs-worker-'));

View file

@ -2,11 +2,7 @@ import { describe, expect, it } from 'vitest';
import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver'; import { TeamMemberResolver } from '../../../../src/main/services/team/TeamMemberResolver';
import type { import type { TeamConfig, TeamTask, TeamTaskWithKanban } from '../../../../src/shared/types/team';
TeamConfig,
TeamTask,
TeamTaskWithKanban,
} from '../../../../src/shared/types/team';
describe('TeamMemberResolver', () => { describe('TeamMemberResolver', () => {
it('builds roster from config + meta + inbox only', () => { 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'); 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', () => { it('filters out "user" pseudo-member even when present in config, meta, or inboxNames', () => {
const resolver = new TeamMemberResolver(); const resolver = new TeamMemberResolver();
const config: TeamConfig = { const config: TeamConfig = {

View file

@ -2546,17 +2546,17 @@ describe('TeamProvisioningService', () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
(svc as any).configReader = { (svc as any).configReader = {
getConfig: vi.fn(async () => ({ getConfig: vi.fn(async () => ({
members: [{ name: 'team-lead', agentType: 'team-lead' }], members: [{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' }],
})), })),
}; };
(svc as any).teamMetaStore = { (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).aliveRunByTeam.set('runtime-team', 'run-1');
(svc as any).runs.set('run-1', { (svc as any).runs.set('run-1', {
runId: 'run-1', runId: 'run-1',
child: { pid: 111 }, child: { pid: 111 },
request: { model: 'gpt-5.4', providerBackendId: 'codex-native' }, request: { providerId: 'codex', model: 'gpt-5.4', providerBackendId: 'codex-native' },
processKilled: false, processKilled: false,
cancelRequested: false, cancelRequested: false,
spawnContext: null, spawnContext: null,
@ -2574,11 +2574,11 @@ describe('TeamProvisioningService', () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
(svc as any).configReader = { (svc as any).configReader = {
getConfig: vi.fn(async () => ({ getConfig: vi.fn(async () => ({
members: [{ name: 'team-lead', agentType: 'team-lead' }], members: [{ name: 'team-lead', agentType: 'team-lead', providerId: 'codex' }],
})), })),
}; };
(svc as any).teamMetaStore = { (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'); const snapshot = await svc.getTeamAgentRuntimeSnapshot('runtime-team');
@ -2586,6 +2586,48 @@ describe('TeamProvisioningService', () => {
expect(snapshot.providerBackendId).toBe('codex-native'); 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 () => { it('falls back to per-pid pidusage reads when batched sampling fails', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
(svc as any).configReader = { (svc as any).configReader = {
@ -13193,6 +13235,81 @@ describe('TeamProvisioningService', () => {
await svc.cancelProvisioning(runId); 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 () => { it('routes a pure OpenCode team directly through the runtime adapter without spawning the CLI lane', async () => {
allowConsoleLogs(); allowConsoleLogs();
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => { const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
@ -14465,13 +14582,14 @@ describe('TeamProvisioningService', () => {
], ],
}; };
expect((svc as any).buildTeammatePermissionUpdatedInput('AskUserQuestion', toolInput, '')) expect(
.toEqual({ (svc as any).buildTeammatePermissionUpdatedInput('AskUserQuestion', toolInput, '')
...toolInput, ).toEqual({
answers: { ...toolInput,
'Anything else?': '', answers: {
}, 'Anything else?': '',
}); },
});
}); });
it('sends teammate AskUserQuestion permission responses to the teammate inbox', async () => { it('sends teammate AskUserQuestion permission responses to the teammate inbox', async () => {

View file

@ -477,6 +477,199 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
expect(result.args).toContain('--anthropic-safe-passthrough'); 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 () => { afterEach(async () => {
await removeTempRoot(tempRoot); await removeTempRoot(tempRoot);
}); });

View file

@ -83,8 +83,15 @@ vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({
})); }));
vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({ vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({
SidebarTaskItem: ({ task }: { task: GlobalTask }) => SidebarTaskItem: ({ task, hideProjectName }: { task: GlobalTask; hideProjectName?: boolean }) =>
React.createElement('div', { 'data-testid': 'sidebar-task-item' }, task.subject), React.createElement(
'div',
{
'data-testid': 'sidebar-task-item',
'data-hide-project-name': hideProjectName ? 'true' : 'false',
},
task.subject
),
})); }));
vi.mock('../../../../src/renderer/components/sidebar/TaskFiltersPopover', () => ({ 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 () => { it('keeps the hard visible limit when new tasks arrive after expansion', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1)); storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1));

View file

@ -30,7 +30,8 @@ vi.mock('../../../../src/renderer/hooks/useTheme', () => ({
})); }));
vi.mock('../../../../src/renderer/components/ui/tooltip', () => ({ 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) => TooltipTrigger: ({ children }: React.PropsWithChildren) =>
React.createElement(React.Fragment, null, children), React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: React.PropsWithChildren) => TooltipContent: ({ children }: React.PropsWithChildren) =>
@ -62,7 +63,7 @@ vi.mock('../../../../src/shared/utils/reviewState', () => ({
})); }));
vi.mock('zustand/react/shallow', () => ({ vi.mock('zustand/react/shallow', () => ({
useShallow: <T,>(selector: T) => selector, useShallow: <T>(selector: T) => selector,
})); }));
vi.mock('lucide-react', () => { vi.mock('lucide-react', () => {
@ -139,4 +140,47 @@ describe('SidebarTaskItem unread styling', () => {
await Promise.resolve(); 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();
});
});
}); });

View file

@ -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'; import { TeamModelSelector } from '@renderer/components/team/dialogs/TeamModelSelector';
describe('TeamModelSelector disabled Codex models', () => { describe('TeamModelSelector disabled Codex models', () => {
@ -96,6 +115,7 @@ describe('TeamModelSelector disabled Codex models', () => {
codexAccountHookState.startChatgptLogin.mockClear(); codexAccountHookState.startChatgptLogin.mockClear();
codexAccountHookState.cancelChatgptLogin.mockClear(); codexAccountHookState.cancelChatgptLogin.mockClear();
codexAccountHookState.logout.mockClear(); codexAccountHookState.logout.mockClear();
useVirtualizerMock.mockClear();
}); });
it('shows only Default while Codex runtime models are still loading', async () => { 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('openai/gpt-oss-120b:free');
expect(host.textContent).toContain('big-pickle'); expect(host.textContent).toContain('big-pickle');
expect(host.textContent).toContain('qwen/qwen3-coder-plus'); 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('openai/gpt-oss-20b:free');
expect(host.textContent).toContain('Not recommended'); expect(host.textContent).toContain('Not recommended');
const groupLabels = Array.from( 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 () => { it('shows short-lived OpenCode preflight failures as unavailable model tiles', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = { 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); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
storeState.cliStatus = { storeState.cliStatus = {
flavor: 'agent_teams_orchestrator', flavor: 'agent_teams_orchestrator',
@ -434,8 +513,8 @@ describe('TeamModelSelector disabled Codex models', () => {
onProviderChange: () => undefined, onProviderChange: () => undefined,
value: '', value: '',
onValueChange, onValueChange,
modelIssueReasonByValue: { modelAdvisoryReasonByValue: {
'openai/gpt-5.4': 'Model verification timed out', '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) => 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).not.toBeNull();
expect(issueButton?.getAttribute('aria-disabled')).toBe('false'); expect(issueButton?.getAttribute('aria-disabled')).toBe('false');
expect(issueButton?.textContent).toContain('Issue'); expect(issueButton?.textContent).toContain('Ping not confirmed');
expect(issueButton?.getAttribute('title')).toContain('Model verification timed out'); 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 () => { await act(async () => {
issueButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); issueButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await Promise.resolve(); await Promise.resolve();
}); });
expect(onValueChange).toHaveBeenCalledWith('openai/gpt-5.4'); expect(onValueChange).toHaveBeenCalledWith('opencode/big-pickle');
await act(async () => { await act(async () => {
root.unmount(); root.unmount();

View file

@ -419,6 +419,10 @@ vi.mock('@renderer/components/team/dialogs/providerPrepareCacheKey', () => ({
vi.mock('@renderer/components/team/dialogs/providerPrepareDiagnostics', () => ({ vi.mock('@renderer/components/team/dialogs/providerPrepareDiagnostics', () => ({
buildReusableProviderPrepareModelResults: () => ({}), buildReusableProviderPrepareModelResults: () => ({}),
getProviderPrepareCachedSnapshot: () => ({ status: 'checking', details: [] }), getProviderPrepareCachedSnapshot: () => ({ status: 'checking', details: [] }),
mergeReusableProviderPrepareModelResults: (
existing: Record<string, unknown> | null | undefined,
next: Record<string, unknown>
) => ({ ...(existing ?? {}), ...next }),
runProviderPrepareDiagnostics: vi.fn(async () => ({ runProviderPrepareDiagnostics: vi.fn(async () => ({
status: 'ready', status: 'ready',
warnings: [], warnings: [],

View file

@ -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', () => { it('preserves literal [1m] suffixes for non-anthropic providers', () => {
const result = resolveLaunchDialogPrefill({ const result = resolveLaunchDialogPrefill({
members: [], members: [],

View file

@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { import {
buildReusableProviderPrepareModelResults, buildReusableProviderPrepareModelResults,
mergeReusableProviderPrepareModelResults,
runProviderPrepareDiagnostics, runProviderPrepareDiagnostics,
} from '@renderer/components/team/dialogs/providerPrepareDiagnostics'; } from '@renderer/components/team/dialogs/providerPrepareDiagnostics';
import { DEFAULT_PROVIDER_MODEL_SELECTION } from '@shared/utils/providerModelSelection'; 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 () => { it('returns a failed provider result immediately when runtime preflight fails', async () => {
const prepareProvisioning = vi const prepareProvisioning = vi
.fn< .fn<

View file

@ -49,9 +49,10 @@ describe('providerPrepareShortLivedCache', () => {
cacheKey: 'key-1', cacheKey: 'key-1',
}) })
).toEqual({ ).toEqual({
modelIssueReasonByValue: { modelAdvisoryReasonByValue: {
'opencode/nemotron-3-super-free': 'timed out', 'opencode/nemotron-3-super-free': 'timed out',
}, },
modelIssueReasonByValue: {},
modelUnavailableReasonByValue: {}, modelUnavailableReasonByValue: {},
}); });
}); });
@ -105,6 +106,7 @@ describe('providerPrepareShortLivedCache', () => {
cacheKey: 'key-4', cacheKey: 'key-4',
}) })
).toEqual({ ).toEqual({
modelAdvisoryReasonByValue: {},
modelIssueReasonByValue: {}, modelIssueReasonByValue: {},
modelUnavailableReasonByValue: { modelUnavailableReasonByValue: {
'openai/gpt-5.4': 'OpenCode provider authentication failed', 'openai/gpt-5.4': 'OpenCode provider authentication failed',
@ -142,11 +144,100 @@ describe('providerPrepareShortLivedCache', () => {
cacheKey: 'key-5', cacheKey: 'key-5',
}) })
).toEqual({ ).toEqual({
modelAdvisoryReasonByValue: {},
modelIssueReasonByValue: {}, modelIssueReasonByValue: {},
modelUnavailableReasonByValue: {}, 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', () => { it('expires short-lived OpenCode issues after the issue TTL', () => {
vi.useFakeTimers(); vi.useFakeTimers();
storeShortLivedProviderPrepareModelResults({ storeShortLivedProviderPrepareModelResults({
@ -169,6 +260,7 @@ describe('providerPrepareShortLivedCache', () => {
cacheKey: 'key-6', cacheKey: 'key-6',
}) })
).toEqual({ ).toEqual({
modelAdvisoryReasonByValue: {},
modelIssueReasonByValue: {}, modelIssueReasonByValue: {},
modelUnavailableReasonByValue: {}, modelUnavailableReasonByValue: {},
}); });
@ -199,6 +291,7 @@ describe('providerPrepareShortLivedCache', () => {
cacheKey: 'key-3', cacheKey: 'key-3',
}) })
).toEqual({ ).toEqual({
modelAdvisoryReasonByValue: {},
modelIssueReasonByValue: {}, modelIssueReasonByValue: {},
modelUnavailableReasonByValue: {}, modelUnavailableReasonByValue: {},
}); });

View file

@ -63,4 +63,42 @@ describe('executeTeamRelaunch', () => {
expect(calls).toEqual(['replace', 'launch']); expect(calls).toEqual(['replace', 'launch']);
expect(stopTeam).not.toHaveBeenCalled(); 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);
});
}); });

View file

@ -313,6 +313,9 @@ describe('MemberCard starting-state visuals', () => {
expect(host.textContent).toContain('registered'); expect(host.textContent).toContain('registered');
expect(host.querySelector('[aria-label="registered"]')).not.toBeNull(); 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 () => { await act(async () => {
root.unmount(); root.unmount();
@ -476,7 +479,6 @@ describe('MemberCard starting-state visuals', () => {
expect(avatarRing?.style.borderColor).toBe('#3b82f6'); expect(avatarRing?.style.borderColor).toBe('#3b82f6');
expect(clickableCard?.style.borderLeft).toBe(''); expect(clickableCard?.style.borderLeft).toBe('');
expect(clickableCard?.style.background).toBe(''); expect(clickableCard?.style.background).toBe('');
expect(clickableCard?.className).not.toContain('px-');
await act(async () => { await act(async () => {
root.unmount(); root.unmount();

View file

@ -134,6 +134,7 @@ describe('MemberList spawn-status memoization', () => {
React.createElement(MemberList, { React.createElement(MemberList, {
members: [], members: [],
expectedTeammateCount: 2, expectedTeammateCount: 2,
isRosterLoading: true,
isTeamAlive: false, isTeamAlive: false,
}) })
); );
@ -168,6 +169,7 @@ describe('MemberList spawn-status memoization', () => {
}, },
], ],
expectedTeammateCount: 2, expectedTeammateCount: 2,
isRosterLoading: true,
isTeamAlive: false, 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 () => { it('rerenders cards when only the hard failure reason changes', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div'); const host = document.createElement('div');

View file

@ -34,7 +34,7 @@ describe('members editor editable input filtering', () => {
name: 'bob', name: 'bob',
agentType: 'developer', agentType: 'developer',
}, },
] satisfies Array<Pick<ResolvedTeamMember, 'name' | 'agentType'>>; ] satisfies Pick<ResolvedTeamMember, 'name' | 'agentType'>[];
expect(filterEditableMemberInputs(members).map((member) => member.name)).toEqual([ expect(filterEditableMemberInputs(members).map((member) => member.name)).toEqual([
'alice', 'alice',
@ -57,9 +57,10 @@ describe('members editor editable input filtering', () => {
model: 'gpt-5.4-mini', model: 'gpt-5.4-mini',
effort: 'medium', effort: 'medium',
}, },
] satisfies Array< ] satisfies Pick<
Pick<ResolvedTeamMember, 'name' | 'agentType' | 'providerId' | 'model' | 'effort'> ResolvedTeamMember,
>; 'name' | 'agentType' | 'providerId' | 'model' | 'effort'
>[];
const drafts = createMemberDraftsFromInputs(filterEditableMemberInputs(members)); const drafts = createMemberDraftsFromInputs(filterEditableMemberInputs(members));
expect(drafts).toHaveLength(1); 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', () => { it('preserves explicit codex models when exporting member inputs', () => {
const drafts = createMemberDraftsFromInputs( const drafts = createMemberDraftsFromInputs(
filterEditableMemberInputs([ filterEditableMemberInputs([
@ -113,9 +318,10 @@ describe('members editor editable input filtering', () => {
model: 'gpt-5.4-mini', model: 'gpt-5.4-mini',
effort: 'medium', effort: 'medium',
}, },
] satisfies Array< ] satisfies Pick<
Pick<ResolvedTeamMember, 'name' | 'agentType' | 'providerId' | 'model' | 'effort'> ResolvedTeamMember,
>) 'name' | 'agentType' | 'providerId' | 'model' | 'effort'
>[])
); );
expect(buildMembersFromDrafts(drafts)).toEqual([ expect(buildMembersFromDrafts(drafts)).toEqual([
@ -140,7 +346,7 @@ describe('members editor editable input filtering', () => {
name: 'bob', name: 'bob',
agentType: 'reviewer', agentType: 'reviewer',
}, },
] satisfies Array<Pick<ResolvedTeamMember, 'name' | 'agentType' | 'isolation'>>) ] satisfies Pick<ResolvedTeamMember, 'name' | 'agentType' | 'isolation'>[])
); );
const exported = buildMembersFromDrafts(drafts); const exported = buildMembersFromDrafts(drafts);

View file

@ -894,7 +894,7 @@ describe('RuntimeProviderManagementPanelView', () => {
expect(host.textContent).toContain('Model probe passed'); expect(host.textContent).toContain('Model probe passed');
expect(host.textContent).toContain('Recommended'); expect(host.textContent).toContain('Recommended');
expect(host.textContent).toContain('Not 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');
expect(host.textContent).toContain('Tested with limits'); expect(host.textContent).toContain('Tested with limits');
expect(host.textContent).toContain('Recommended only'); expect(host.textContent).toContain('Recommended only');

View file

@ -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 () => { it('rolls back optimistic pending run on early createTeam failure', async () => {
const store = createSliceStore(); 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')); hoisted.createTeam.mockRejectedValue(new Error('create failed'));
await expect( await expect(
@ -5029,12 +5254,16 @@ describe('teamSlice actions', () => {
teamName: 'my-team', teamName: 'my-team',
cwd: '/tmp/project', cwd: '/tmp/project',
members: [], members: [],
providerId: 'anthropic',
model: 'sonnet',
effort: 'low',
}) })
).rejects.toThrow('create failed'); ).rejects.toThrow('create failed');
expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined(); expect(store.getState().currentProvisioningRunIdByTeam['my-team']).toBeUndefined();
expect(Object.values(store.getState().provisioningRuns)).toHaveLength(0); expect(Object.values(store.getState().provisioningRuns)).toHaveLength(0);
expect(store.getState().provisioningErrorByTeam['my-team']).toBe('create failed'); 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', () => { it('hydrates visible non-selected graph tabs when config becomes ready', () => {

View file

@ -156,6 +156,204 @@ describe('resolveMemberRuntimeSummary', () => {
).toBe('5.4 Mini · Medium · Codex'); ).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', () => { it('normalizes persisted legacy Codex lanes to the native runtime summary', () => {
const member = createMember({ model: 'gpt-5.4-mini' }); const member = createMember({ model: 'gpt-5.4-mini' });

View file

@ -165,579 +165,579 @@ describe('getOpenCodeTeamModelRecommendation', () => {
it('marks OpenRouter routes missing from the OpenCode catalog as unavailable, not bad', () => { it('marks OpenRouter routes missing from the OpenCode catalog as unavailable, not bad', () => {
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder-plus')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder-plus')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder-next')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder-next')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder:free')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-coder:free')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-next-80b-a3b-instruct:free') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-next-80b-a3b-instruct:free')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/google/gemini-2.0-flash-lite-001') getOpenCodeTeamModelRecommendation('openrouter/google/gemini-2.0-flash-lite-001')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-4.1-nano')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-4.1-nano')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-4o-mini-2024-07-18') getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-4o-mini-2024-07-18')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-4o-mini-search-preview') getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-4o-mini-search-preview')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-plus')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-plus')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-turbo')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-turbo')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-235b-a22b-2507') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-235b-a22b-2507')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/deepseek/deepseek-v3.2-exp') getOpenCodeTeamModelRecommendation('openrouter/deepseek/deepseek-v3.2-exp')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-32b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-32b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-14b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-14b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-8b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-8b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwq-32b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwq-32b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/deepseek/deepseek-chat')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/deepseek/deepseek-chat')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-nemo')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-nemo')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-small-24b-instruct-2501') getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-small-24b-instruct-2501')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/cohere/command-r7b-12-2024') getOpenCodeTeamModelRecommendation('openrouter/cohere/command-r7b-12-2024')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/cohere/command-r-08-2024')).toMatchObject( expect(getOpenCodeTeamModelRecommendation('openrouter/cohere/command-r-08-2024')).toMatchObject(
{ {
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
} }
); );
expect(getOpenCodeTeamModelRecommendation('openrouter/rekaai/reka-flash-3')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/rekaai/reka-flash-3')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/rekaai/reka-edge')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/rekaai/reka-edge')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/nvidia/nemotron-3-nano-30b-a3b') getOpenCodeTeamModelRecommendation('openrouter/nvidia/nemotron-3-nano-30b-a3b')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-01')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-01')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/nvidia/llama-3.3-nemotron-super-49b-v1.5') getOpenCodeTeamModelRecommendation('openrouter/nvidia/llama-3.3-nemotron-super-49b-v1.5')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-max-thinking')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-max-thinking')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-large-2512') getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-large-2512')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/devstral-medium') getOpenCodeTeamModelRecommendation('openrouter/mistralai/devstral-medium')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/devstral-small')).toMatchObject( expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/devstral-small')).toMatchObject(
{ {
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
} }
); );
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/ministral-14b-2512') getOpenCodeTeamModelRecommendation('openrouter/mistralai/ministral-14b-2512')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/ministral-8b-2512') getOpenCodeTeamModelRecommendation('openrouter/mistralai/ministral-8b-2512')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/ministral-3b-2512') getOpenCodeTeamModelRecommendation('openrouter/mistralai/ministral-3b-2512')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-m2-her')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/minimax/minimax-m2-her')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/xiaomi/mimo-v2.5')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/xiaomi/mimo-v2.5')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/xiaomi/mimo-v2.5-pro')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/xiaomi/mimo-v2.5-pro')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4.20')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4.20')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/google/gemini-3.1-flash-image-preview') getOpenCodeTeamModelRecommendation('openrouter/google/gemini-3.1-flash-image-preview')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-5v-turbo')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-5v-turbo')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4.20-multi-agent') getOpenCodeTeamModelRecommendation('openrouter/x-ai/grok-4.20-multi-agent')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-small-creative') getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-small-creative')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.3-chat')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5.3-chat')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/voxtral-small-24b-2507') getOpenCodeTeamModelRecommendation('openrouter/mistralai/voxtral-small-24b-2507')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5-chat')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-5-chat')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-2.5-72b-instruct') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-2.5-72b-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/tngtech/deepseek-r1t2-chimera') getOpenCodeTeamModelRecommendation('openrouter/tngtech/deepseek-r1t2-chimera')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/google/gemini-2.5-pro-preview') getOpenCodeTeamModelRecommendation('openrouter/google/gemini-2.5-pro-preview')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-saba')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-saba')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-large-2411') getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-large-2411')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-30b-a3b-instruct') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-30b-a3b-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/inclusionai/ling-2.6-1t:free') getOpenCodeTeamModelRecommendation('openrouter/inclusionai/ling-2.6-1t:free')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/inclusionai/ling-2.6-flash:free') getOpenCodeTeamModelRecommendation('openrouter/inclusionai/ling-2.6-flash:free')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/meta-llama/llama-3.1-8b-instruct') getOpenCodeTeamModelRecommendation('openrouter/meta-llama/llama-3.1-8b-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-2.5-7b-instruct') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-2.5-7b-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/amazon/nova-lite-v1')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/amazon/nova-lite-v1')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-4-32b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-4-32b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/bytedance-seed/seed-1.6-flash') getOpenCodeTeamModelRecommendation('openrouter/bytedance-seed/seed-1.6-flash')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/meta-llama/llama-4-scout')).toMatchObject( expect(getOpenCodeTeamModelRecommendation('openrouter/meta-llama/llama-4-scout')).toMatchObject(
{ {
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
} }
); );
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/meta-llama/llama-3.3-70b-instruct') getOpenCodeTeamModelRecommendation('openrouter/meta-llama/llama-3.3-70b-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/bytedance-seed/seed-2.0-mini') getOpenCodeTeamModelRecommendation('openrouter/bytedance-seed/seed-2.0-mini')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-32b-instruct') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-32b-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/alibaba/tongyi-deepresearch-30b-a3b') getOpenCodeTeamModelRecommendation('openrouter/alibaba/tongyi-deepresearch-30b-a3b')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/arcee-ai/trinity-large-preview') getOpenCodeTeamModelRecommendation('openrouter/arcee-ai/trinity-large-preview')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/amazon/nova-micro-v1')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/amazon/nova-micro-v1')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/arcee-ai/trinity-mini')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/arcee-ai/trinity-mini')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3.5-9b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3.5-9b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/essentialai/rnj-1-instruct') getOpenCodeTeamModelRecommendation('openrouter/essentialai/rnj-1-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/upstage/solar-pro-3')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/upstage/solar-pro-3')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/allenai/olmo-3.1-32b-instruct') getOpenCodeTeamModelRecommendation('openrouter/allenai/olmo-3.1-32b-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-plus-2025-07-28') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-plus-2025-07-28')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/tencent/hy3-preview:free')).toMatchObject( expect(getOpenCodeTeamModelRecommendation('openrouter/tencent/hy3-preview:free')).toMatchObject(
{ {
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
} }
); );
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-8b-instruct') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-8b-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/nex-agi/deepseek-v3.1-nex-n1') getOpenCodeTeamModelRecommendation('openrouter/nex-agi/deepseek-v3.1-nex-n1')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/baidu/ernie-4.5-vl-28b-a3b') getOpenCodeTeamModelRecommendation('openrouter/baidu/ernie-4.5-vl-28b-a3b')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/thedrummer/rocinante-12b')).toMatchObject( expect(getOpenCodeTeamModelRecommendation('openrouter/thedrummer/rocinante-12b')).toMatchObject(
{ {
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
} }
); );
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/meta-llama/llama-3.1-70b-instruct') getOpenCodeTeamModelRecommendation('openrouter/meta-llama/llama-3.1-70b-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-plus-2025-07-28:thinking') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-plus-2025-07-28:thinking')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-4.6v')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/z-ai/glm-4.6v')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/anthropic/claude-3-haiku')).toMatchObject( expect(getOpenCodeTeamModelRecommendation('openrouter/anthropic/claude-3-haiku')).toMatchObject(
{ {
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
} }
); );
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/bytedance-seed/seed-2.0-lite') getOpenCodeTeamModelRecommendation('openrouter/bytedance-seed/seed-2.0-lite')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-235b-a22b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-235b-a22b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3.5-122b-a10b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3.5-122b-a10b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/deepseek/deepseek-r1-0528') getOpenCodeTeamModelRecommendation('openrouter/deepseek/deepseek-r1-0528')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/amazon/nova-2-lite-v1')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/amazon/nova-2-lite-v1')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/o3-mini')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/openai/o3-mini')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-large-2407') getOpenCodeTeamModelRecommendation('openrouter/mistralai/mistral-large-2407')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/thedrummer/unslopnemo-12b') getOpenCodeTeamModelRecommendation('openrouter/thedrummer/unslopnemo-12b')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-235b-a22b-instruct') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-235b-a22b-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-8b-thinking') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-8b-thinking')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/kwaipilot/kat-coder-pro-v2') getOpenCodeTeamModelRecommendation('openrouter/kwaipilot/kat-coder-pro-v2')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/o4-mini-high')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/openai/o4-mini-high')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/o3-mini-high')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/openai/o3-mini-high')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-4o')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-4o')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/cohere/command-r-plus-08-2024') getOpenCodeTeamModelRecommendation('openrouter/cohere/command-r-plus-08-2024')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-30b-a3b-thinking') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-30b-a3b-thinking')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/sao10k/l3.1-euryale-70b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/sao10k/l3.1-euryale-70b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3.5-27b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3.5-27b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/arcee-ai/virtuoso-large')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/arcee-ai/virtuoso-large')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-3.5-turbo')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-3.5-turbo')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/bytedance-seed/seed-1.6')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/bytedance-seed/seed-1.6')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/nvidia/llama-3.1-nemotron-70b-instruct') getOpenCodeTeamModelRecommendation('openrouter/nvidia/llama-3.1-nemotron-70b-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-vl-max')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-vl-max')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-235b-a22b-thinking') getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-vl-235b-a22b-thinking')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-audio-mini')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/openai/gpt-audio-mini')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/amazon/nova-pro-v1')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/amazon/nova-pro-v1')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/relace/relace-search')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/relace/relace-search')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-max')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen-max')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/pixtral-large-2411') getOpenCodeTeamModelRecommendation('openrouter/mistralai/pixtral-large-2411')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/mistralai/mixtral-8x22b-instruct') getOpenCodeTeamModelRecommendation('openrouter/mistralai/mixtral-8x22b-instruct')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3.5-35b-a3b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3.5-35b-a3b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-30b-a3b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/qwen/qwen3-30b-a3b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect(getOpenCodeTeamModelRecommendation('openrouter/baidu/ernie-4.5-21b-a3b')).toMatchObject({ expect(getOpenCodeTeamModelRecommendation('openrouter/baidu/ernie-4.5-21b-a3b')).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
expect( expect(
getOpenCodeTeamModelRecommendation('openrouter/nousresearch/hermes-4-70b') getOpenCodeTeamModelRecommendation('openrouter/nousresearch/hermes-4-70b')
).toMatchObject({ ).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
for (const modelId of [ for (const modelId of [
'openrouter/openai/gpt-3.5-turbo-16k', 'openrouter/openai/gpt-3.5-turbo-16k',
@ -793,7 +793,7 @@ describe('getOpenCodeTeamModelRecommendation', () => {
]) { ]) {
expect(getOpenCodeTeamModelRecommendation(modelId)).toMatchObject({ expect(getOpenCodeTeamModelRecommendation(modelId)).toMatchObject({
level: 'unavailable-in-opencode', level: 'unavailable-in-opencode',
label: 'Unavailable in OpenCode', label: 'Not verified in OpenCode',
}); });
} }
expect(isOpenCodeTeamModelRecommended('openrouter/qwen/qwen3-coder-plus')).toBe(false); expect(isOpenCodeTeamModelRecommended('openrouter/qwen/qwen3-coder-plus')).toBe(false);

View file

@ -85,6 +85,14 @@ describe('Shared Pricing Module', () => {
warnSpy.mockRestore(); 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', () => { it('should include cache token costs', () => {
const cost = calculateMessageCost('claude-4-sonnet-20250514', 1000, 500, 300, 200); const cost = calculateMessageCost('claude-4-sonnet-20250514', 1000, 500, 300, 200);
expect(cost).toBeGreaterThan(0.0105); expect(cost).toBeGreaterThan(0.0105);

View 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();
});
});