feat(team): improve runtime provider workflows
This commit is contained in:
parent
4087060cca
commit
445932e45b
84 changed files with 7004 additions and 998 deletions
17
.mcp.json
Normal file
17
.mcp.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"playwright-electron": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@playwright/mcp@0.0.75",
|
||||||
|
"--cdp-endpoint",
|
||||||
|
"http://127.0.0.1:9222",
|
||||||
|
"--caps",
|
||||||
|
"devtools",
|
||||||
|
"--console-level",
|
||||||
|
"info"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
34
README.md
34
README.md
|
|
@ -278,6 +278,9 @@ pnpm dev
|
||||||
|
|
||||||
`pnpm dev` starts the desktop Electron app. Do not start a browser/web dev server for normal development; that path is limited and is not the supported way to run agent teams locally.
|
`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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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] : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:') ||
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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'}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 ?? {}),
|
||||||
|
|
|
||||||
|
|
@ -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 } : {}),
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
550
test/main/services/team/AnthropicLaunchSelection.live.test.ts
Normal file
550
test/main/services/team/AnthropicLaunchSelection.live.test.ts
Normal file
|
|
@ -0,0 +1,550 @@
|
||||||
|
import { constants as fsConstants, promises as fs } from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||||
|
import {
|
||||||
|
encodePath,
|
||||||
|
encodePathPortable,
|
||||||
|
getTasksBasePath,
|
||||||
|
getTeamsBasePath,
|
||||||
|
setClaudeBasePathOverride,
|
||||||
|
} from '../../../../src/main/utils/pathDecoder';
|
||||||
|
import { killProcessByPid } from '../../../../src/main/utils/processKill';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
TeamAgentRuntimeSnapshot,
|
||||||
|
TeamCreateRequest,
|
||||||
|
TeamMember,
|
||||||
|
TeamProvisioningProgress,
|
||||||
|
} from '../../../../src/shared/types';
|
||||||
|
|
||||||
|
vi.mock('../../../../src/main/services/infrastructure/NotificationManager', () => ({
|
||||||
|
NotificationManager: {
|
||||||
|
getInstance: () => ({
|
||||||
|
addTeamNotification: vi.fn(async () => undefined),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const liveDescribe =
|
||||||
|
process.env.ANTHROPIC_LAUNCH_SELECTION_LIVE === '1' &&
|
||||||
|
(Boolean(process.env.ANTHROPIC_API_KEY?.trim()) || usingAnthropicSubscriptionAuth())
|
||||||
|
? describe
|
||||||
|
: describe.skip;
|
||||||
|
|
||||||
|
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
|
||||||
|
const DEFAULT_LEAD_MODEL = 'sonnet';
|
||||||
|
const DEFAULT_MEMBER_MODEL = 'haiku';
|
||||||
|
const DEFAULT_LEAD_EFFORT = 'low' as const;
|
||||||
|
|
||||||
|
liveDescribe('Anthropic launch selection live e2e', () => {
|
||||||
|
let tempDir: string;
|
||||||
|
let tempClaudeRoot: string;
|
||||||
|
let tempHome: string;
|
||||||
|
let projectPath: string;
|
||||||
|
let previousCliPath: string | undefined;
|
||||||
|
let previousCliFlavor: string | undefined;
|
||||||
|
let previousHome: string | undefined;
|
||||||
|
let previousUserProfile: string | undefined;
|
||||||
|
let previousNodeEnv: string | undefined;
|
||||||
|
let previousAnthropicApiKey: string | undefined;
|
||||||
|
let previousAnthropicAuthToken: string | undefined;
|
||||||
|
let previousDisableAppBootstrap: string | undefined;
|
||||||
|
let previousDisableRuntimeBootstrap: string | undefined;
|
||||||
|
let previousClaudeJsonConfig: string | null | undefined;
|
||||||
|
let svc: TeamProvisioningService | null;
|
||||||
|
let teamName: string | null;
|
||||||
|
let subscriptionAuth = false;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
subscriptionAuth = usingAnthropicSubscriptionAuth();
|
||||||
|
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'anthropic-launch-selection-live-'));
|
||||||
|
tempClaudeRoot = subscriptionAuth ? os.userInfo().homedir : path.join(tempDir, '.claude');
|
||||||
|
tempHome = path.join(tempDir, 'home');
|
||||||
|
projectPath = path.join(tempDir, 'project');
|
||||||
|
if (!subscriptionAuth) {
|
||||||
|
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||||
|
}
|
||||||
|
await fs.mkdir(tempHome, { recursive: true });
|
||||||
|
await fs.mkdir(projectPath, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(projectPath, 'README.md'),
|
||||||
|
'# Anthropic launch selection live e2e\n\nKeep this project intentionally tiny.\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (subscriptionAuth) {
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
previousClaudeJsonConfig = await upsertTrustedClaudeProjectConfig(
|
||||||
|
tempClaudeRoot,
|
||||||
|
projectPath
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await writeTrustedClaudeConfig(tempClaudeRoot, projectPath);
|
||||||
|
setClaudeBasePathOverride(tempClaudeRoot);
|
||||||
|
previousClaudeJsonConfig = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
|
||||||
|
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||||
|
previousHome = process.env.HOME;
|
||||||
|
previousUserProfile = process.env.USERPROFILE;
|
||||||
|
previousNodeEnv = process.env.NODE_ENV;
|
||||||
|
previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY;
|
||||||
|
previousAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||||
|
previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||||
|
|
||||||
|
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
|
||||||
|
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||||
|
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
|
||||||
|
process.env.HOME = subscriptionAuth ? os.userInfo().homedir : tempHome;
|
||||||
|
process.env.USERPROFILE = subscriptionAuth ? os.userInfo().homedir : tempHome;
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
delete process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||||
|
delete process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||||
|
if (subscriptionAuth) {
|
||||||
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
delete process.env.ANTHROPIC_AUTH_TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
svc = null;
|
||||||
|
teamName = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
const beforeStopSnapshot = svc && teamName ? await safeRuntimeSnapshot(svc, teamName) : null;
|
||||||
|
if (svc && teamName) {
|
||||||
|
await svc.stopTeam(teamName).catch(() => undefined);
|
||||||
|
}
|
||||||
|
await terminateSmokeOwnedProcessBackends(beforeStopSnapshot);
|
||||||
|
const afterStopSnapshot = svc && teamName ? await safeRuntimeSnapshot(svc, teamName) : null;
|
||||||
|
await terminateSmokeOwnedProcessBackends(afterStopSnapshot);
|
||||||
|
|
||||||
|
if (subscriptionAuth && projectPath) {
|
||||||
|
await removeClaudeProjectArtifacts(tempClaudeRoot, projectPath);
|
||||||
|
}
|
||||||
|
if (subscriptionAuth && teamName) {
|
||||||
|
await removeTeamArtifacts(teamName);
|
||||||
|
}
|
||||||
|
if (subscriptionAuth && previousClaudeJsonConfig !== undefined) {
|
||||||
|
await restoreClaudeJsonConfig(tempClaudeRoot, previousClaudeJsonConfig);
|
||||||
|
}
|
||||||
|
setClaudeBasePathOverride(null);
|
||||||
|
|
||||||
|
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
|
||||||
|
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
|
||||||
|
restoreEnv('HOME', previousHome);
|
||||||
|
restoreEnv('USERPROFILE', previousUserProfile);
|
||||||
|
restoreEnv('NODE_ENV', previousNodeEnv);
|
||||||
|
restoreEnv('ANTHROPIC_API_KEY', previousAnthropicApiKey);
|
||||||
|
restoreEnv('ANTHROPIC_AUTH_TOKEN', previousAnthropicAuthToken);
|
||||||
|
restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap);
|
||||||
|
restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap);
|
||||||
|
|
||||||
|
if (process.env.ANTHROPIC_LAUNCH_SELECTION_KEEP_TEMP === '1') {
|
||||||
|
process.stderr.write(`[AnthropicLaunchSelection.live] preserved temp dir: ${tempDir}\n`);
|
||||||
|
} else {
|
||||||
|
await removeTempDirWithRetries(tempDir);
|
||||||
|
}
|
||||||
|
if (subscriptionAuth && projectPath) {
|
||||||
|
await removeClaudeProjectArtifacts(tempClaudeRoot, projectPath);
|
||||||
|
}
|
||||||
|
if (subscriptionAuth && teamName) {
|
||||||
|
await removeTeamArtifacts(teamName);
|
||||||
|
}
|
||||||
|
if (subscriptionAuth && (projectPath || teamName)) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10_000));
|
||||||
|
}
|
||||||
|
if (subscriptionAuth && projectPath) {
|
||||||
|
await removeClaudeProjectArtifacts(tempClaudeRoot, projectPath);
|
||||||
|
}
|
||||||
|
if (subscriptionAuth && teamName) {
|
||||||
|
await removeTeamArtifacts(teamName);
|
||||||
|
}
|
||||||
|
}, 180_000);
|
||||||
|
|
||||||
|
it('launches Sonnet low lead with explicit Haiku teammate without inherited effort', async () => {
|
||||||
|
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
|
||||||
|
expect(orchestratorCli).toBeTruthy();
|
||||||
|
await assertExecutable(orchestratorCli!);
|
||||||
|
|
||||||
|
const leadModel =
|
||||||
|
process.env.ANTHROPIC_LAUNCH_SELECTION_LEAD_MODEL?.trim() || DEFAULT_LEAD_MODEL;
|
||||||
|
const memberModel =
|
||||||
|
process.env.ANTHROPIC_LAUNCH_SELECTION_MEMBER_MODEL?.trim() || DEFAULT_MEMBER_MODEL;
|
||||||
|
const leadEffort = (process.env.ANTHROPIC_LAUNCH_SELECTION_LEAD_EFFORT?.trim() ||
|
||||||
|
DEFAULT_LEAD_EFFORT) as TeamCreateRequest['effort'];
|
||||||
|
|
||||||
|
svc = new TeamProvisioningService();
|
||||||
|
teamName = `anthropic-launch-selection-live-${Date.now()}`;
|
||||||
|
const progressEvents: TeamProvisioningProgress[] = [];
|
||||||
|
|
||||||
|
const response = await svc.createTeam(
|
||||||
|
{
|
||||||
|
teamName,
|
||||||
|
cwd: projectPath,
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: leadModel,
|
||||||
|
effort: leadEffort,
|
||||||
|
skipPermissions: true,
|
||||||
|
prompt: 'Keep the team idle after bootstrap. Do not start extra work.',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
name: 'jack',
|
||||||
|
role: 'Reviewer',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: memberModel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'alice',
|
||||||
|
role: 'Developer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
(progress) => {
|
||||||
|
progressEvents.push(progress);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const run = (
|
||||||
|
svc as unknown as { runs: Map<string, { allEffectiveMembers?: TeamMember[] }> }
|
||||||
|
).runs.get(response.runId);
|
||||||
|
expect(run?.allEffectiveMembers).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'jack',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: memberModel,
|
||||||
|
effort: undefined,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'alice',
|
||||||
|
providerId: 'anthropic',
|
||||||
|
model: leadModel,
|
||||||
|
effort: leadEffort,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await waitUntil(async () => {
|
||||||
|
const last = progressEvents.at(-1);
|
||||||
|
if (last?.state === 'failed') {
|
||||||
|
throw new Error(formatProgressDump(progressEvents));
|
||||||
|
}
|
||||||
|
return last?.state === 'ready';
|
||||||
|
}, 360_000);
|
||||||
|
|
||||||
|
await waitUntil(
|
||||||
|
async () => {
|
||||||
|
const statuses = await svc!.getMemberSpawnStatuses(teamName!);
|
||||||
|
if (statuses.teamLaunchState === 'partial_failure') {
|
||||||
|
throw new Error(await formatLaunchDiagnostics(svc!, teamName!, progressEvents));
|
||||||
|
}
|
||||||
|
return ['jack', 'alice'].every((memberName) => {
|
||||||
|
const member = statuses.statuses[memberName];
|
||||||
|
return (
|
||||||
|
member?.status === 'online' &&
|
||||||
|
member.launchState === 'confirmed_alive' &&
|
||||||
|
member.bootstrapConfirmed === true
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
240_000,
|
||||||
|
2_000,
|
||||||
|
() => formatLaunchDiagnostics(svc!, teamName!, progressEvents)
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitUntil(
|
||||||
|
async () => {
|
||||||
|
const snapshot = await svc!.getTeamAgentRuntimeSnapshot(teamName!);
|
||||||
|
return (
|
||||||
|
snapshot.members.jack?.providerId === 'anthropic' &&
|
||||||
|
snapshot.members.jack.alive === true &&
|
||||||
|
snapshot.members.alice?.providerId === 'anthropic' &&
|
||||||
|
snapshot.members.alice.alive === true
|
||||||
|
);
|
||||||
|
},
|
||||||
|
180_000,
|
||||||
|
2_000,
|
||||||
|
() => formatLaunchDiagnostics(svc!, teamName!, progressEvents)
|
||||||
|
);
|
||||||
|
}, 480_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
function usingAnthropicSubscriptionAuth(): boolean {
|
||||||
|
const mode = process.env.ANTHROPIC_LAUNCH_SELECTION_AUTH?.trim().toLowerCase();
|
||||||
|
return mode === 'subscription' || mode === 'oauth';
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreEnv(name: string, previous: string | undefined): void {
|
||||||
|
if (previous === undefined) {
|
||||||
|
delete process.env[name];
|
||||||
|
} else {
|
||||||
|
process.env[name] = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertExecutable(filePath: string): Promise<void> {
|
||||||
|
await fs.access(filePath, fsConstants.X_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
||||||
|
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||||
|
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
||||||
|
const approvedApiKeySuffix = process.env.ANTHROPIC_API_KEY?.trim().slice(-20);
|
||||||
|
const config: {
|
||||||
|
projects: Record<string, { hasTrustDialogAccepted: true }>;
|
||||||
|
customApiKeyResponses?: { approved: string[]; rejected: string[] };
|
||||||
|
} = {
|
||||||
|
projects: {
|
||||||
|
[normalizedProjectPath]: {
|
||||||
|
hasTrustDialogAccepted: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (approvedApiKeySuffix) {
|
||||||
|
config.customApiKeyResponses = {
|
||||||
|
approved: [approvedApiKeySuffix],
|
||||||
|
rejected: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(configDir, '.claude.json'),
|
||||||
|
`${JSON.stringify(config, null, 2)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertTrustedClaudeProjectConfig(
|
||||||
|
configDir: string,
|
||||||
|
projectPath: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const configPath = path.join(configDir, '.claude.json');
|
||||||
|
const previous = await fs.readFile(configPath, 'utf8').catch((error) => {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const existing = parseJsonObject(previous) ?? {};
|
||||||
|
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => projectPath);
|
||||||
|
const normalizedProjectPath = path.normalize(canonicalProjectPath).replace(/\\/g, '/');
|
||||||
|
const projects =
|
||||||
|
existing.projects && typeof existing.projects === 'object' && !Array.isArray(existing.projects)
|
||||||
|
? { ...(existing.projects as Record<string, unknown>) }
|
||||||
|
: {};
|
||||||
|
const currentProject =
|
||||||
|
projects[normalizedProjectPath] &&
|
||||||
|
typeof projects[normalizedProjectPath] === 'object' &&
|
||||||
|
!Array.isArray(projects[normalizedProjectPath])
|
||||||
|
? (projects[normalizedProjectPath] as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
projects[normalizedProjectPath] = {
|
||||||
|
...currentProject,
|
||||||
|
hasTrustDialogAccepted: true,
|
||||||
|
};
|
||||||
|
await fs.mkdir(configDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
configPath,
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
...existing,
|
||||||
|
projects,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)}\n`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreClaudeJsonConfig(configDir: string, previous: string | null): Promise<void> {
|
||||||
|
const configPath = path.join(configDir, '.claude.json');
|
||||||
|
if (previous === null) {
|
||||||
|
await fs.rm(configPath, { force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await fs.writeFile(configPath, previous, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJsonObject(raw: string | null): Record<string, unknown> | null {
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTempDirWithRetries(dirPath: string): Promise<void> {
|
||||||
|
const attempts = process.platform === 'win32' ? 20 : 5;
|
||||||
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
await fs.rm(dirPath, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
if ((code !== 'EBUSY' && code !== 'EPERM' && code !== 'ENOTEMPTY') || attempt === attempts) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTeamArtifacts(teamName: string): Promise<void> {
|
||||||
|
const targets = [path.join(getTeamsBasePath(), teamName), path.join(getTasksBasePath(), teamName)];
|
||||||
|
for (let attempt = 1; attempt <= 10; attempt += 1) {
|
||||||
|
await Promise.all(targets.map((target) => fs.rm(target, { recursive: true, force: true })));
|
||||||
|
const stillExists = await Promise.all(targets.map(pathExists));
|
||||||
|
if (!stillExists.some(Boolean)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
await Promise.all(targets.map((target) => fs.rm(target, { recursive: true, force: true })));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeClaudeProjectArtifacts(configDir: string, projectPath: string): Promise<void> {
|
||||||
|
const projectPaths = new Set([projectPath]);
|
||||||
|
if (projectPath.startsWith('/var/')) {
|
||||||
|
projectPaths.add(`/private${projectPath}`);
|
||||||
|
} else if (projectPath.startsWith('/private/var/')) {
|
||||||
|
projectPaths.add(projectPath.slice('/private'.length));
|
||||||
|
}
|
||||||
|
const canonicalProjectPath = await fs.realpath(projectPath).catch(() => null);
|
||||||
|
if (canonicalProjectPath) {
|
||||||
|
projectPaths.add(canonicalProjectPath);
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(projectPaths)
|
||||||
|
.flatMap((candidatePath) => [encodePath(candidatePath), encodePathPortable(candidatePath)])
|
||||||
|
.filter(Boolean)
|
||||||
|
.flatMap((encodedProjectPath) =>
|
||||||
|
[
|
||||||
|
path.join(configDir, 'projects', encodedProjectPath),
|
||||||
|
path.join(configDir, '.claude', 'projects', encodedProjectPath),
|
||||||
|
].map((projectDir) =>
|
||||||
|
fs.rm(projectDir, {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pathExists(targetPath: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await fs.access(targetPath);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeRuntimeSnapshot(
|
||||||
|
svc: TeamProvisioningService,
|
||||||
|
teamName: string
|
||||||
|
): Promise<TeamAgentRuntimeSnapshot | null> {
|
||||||
|
return svc.getTeamAgentRuntimeSnapshot(teamName).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminateSmokeOwnedProcessBackends(
|
||||||
|
snapshot: TeamAgentRuntimeSnapshot | null
|
||||||
|
): Promise<void> {
|
||||||
|
const pids = new Set<number>();
|
||||||
|
for (const member of Object.values(snapshot?.members ?? {})) {
|
||||||
|
if (member.backendType !== 'process' || member.providerId !== 'anthropic') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const pid = member.runtimePid ?? member.pid;
|
||||||
|
if (typeof pid === 'number' && Number.isFinite(pid) && pid > 0) {
|
||||||
|
pids.add(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pid of pids) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
killProcessByPid(pid);
|
||||||
|
} catch {
|
||||||
|
// Already gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitUntil(
|
||||||
|
predicate: () => Promise<boolean>,
|
||||||
|
timeoutMs: number,
|
||||||
|
pollMs = 1_000,
|
||||||
|
describeState?: () => string | Promise<string>
|
||||||
|
): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
let lastError: unknown;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
if (await predicate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastError = undefined;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||||
|
}
|
||||||
|
const suffix =
|
||||||
|
lastError instanceof Error && lastError.message ? ` Last error: ${lastError.message}` : '';
|
||||||
|
const state = describeState ? ` Last state: ${await describeState()}` : '';
|
||||||
|
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}${state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function formatLaunchDiagnostics(
|
||||||
|
svc: TeamProvisioningService,
|
||||||
|
teamName: string,
|
||||||
|
progressEvents: TeamProvisioningProgress[]
|
||||||
|
): Promise<string> {
|
||||||
|
const [spawnStatuses, runtimeSnapshot] = await Promise.all([
|
||||||
|
svc.getMemberSpawnStatuses(teamName).catch((error) => ({ error: String(error) })),
|
||||||
|
svc.getTeamAgentRuntimeSnapshot(teamName).catch((error) => ({ error: String(error) })),
|
||||||
|
]);
|
||||||
|
return redactSecrets(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
progress: formatProgressDump(progressEvents),
|
||||||
|
spawnStatuses,
|
||||||
|
runtimeSnapshot,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatProgressDump(progressEvents: TeamProvisioningProgress[]): string {
|
||||||
|
return redactSecrets(
|
||||||
|
progressEvents
|
||||||
|
.map((progress) =>
|
||||||
|
[
|
||||||
|
progress.state,
|
||||||
|
progress.message,
|
||||||
|
progress.messageSeverity,
|
||||||
|
progress.error,
|
||||||
|
progress.cliLogsTail,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' | ')
|
||||||
|
)
|
||||||
|
.join('\n')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function redactSecrets(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/sk-ant-api03-[A-Za-z0-9_-]+/g, '<redacted-anthropic-key>')
|
||||||
|
.replace(/\b(?:sk|ak)-[A-Za-z0-9_-]{20,}\b/g, '<redacted-api-key>');
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,12 @@ const SUMMARY_OPTIONS = {
|
||||||
summaryOnly: true,
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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> = {}
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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')],
|
||||||
|
|
|
||||||
|
|
@ -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-'));
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
|
|
|
||||||
|
|
@ -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<
|
||||||
|
|
|
||||||
|
|
@ -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: {},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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' });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
30
test/shared/utils/providerBackend.test.ts
Normal file
30
test/shared/utils/providerBackend.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
formatProviderBackendLabel,
|
||||||
|
migrateProviderBackendId,
|
||||||
|
} from '../../../src/shared/utils/providerBackend';
|
||||||
|
|
||||||
|
describe('providerBackend utils', () => {
|
||||||
|
it('does not let Codex backends leak into Anthropic selections', () => {
|
||||||
|
expect(migrateProviderBackendId('anthropic', 'codex-native')).toBeUndefined();
|
||||||
|
expect(formatProviderBackendLabel('anthropic', 'codex-native')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Codex native defaults and legacy backend migration scoped to Codex', () => {
|
||||||
|
expect(migrateProviderBackendId('codex', undefined)).toBe('codex-native');
|
||||||
|
expect(migrateProviderBackendId('codex', 'api')).toBe('codex-native');
|
||||||
|
expect(migrateProviderBackendId('codex', 'adapter')).toBe('codex-native');
|
||||||
|
expect(migrateProviderBackendId('codex', 'opencode-cli')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps Gemini and OpenCode backend ids provider-specific', () => {
|
||||||
|
expect(migrateProviderBackendId('gemini', 'api')).toBe('api');
|
||||||
|
expect(migrateProviderBackendId('gemini', 'cli-sdk')).toBe('cli-sdk');
|
||||||
|
expect(migrateProviderBackendId('gemini', 'codex-native')).toBeUndefined();
|
||||||
|
expect(migrateProviderBackendId('opencode', 'opencode-cli')).toBe('opencode-cli');
|
||||||
|
expect(migrateProviderBackendId('opencode', 'adapter')).toBe('adapter');
|
||||||
|
expect(migrateProviderBackendId('opencode', 'codex-native')).toBeUndefined();
|
||||||
|
expect(migrateProviderBackendId(undefined, 'codex-native')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue