merge(dev): sync dev into main
This commit is contained in:
commit
9ff14a6e0b
71 changed files with 14196 additions and 486 deletions
|
|
@ -308,7 +308,6 @@ pnpm dist # macOS + Windows + Linux
|
|||
|
||||
- [ ] Planning mode to organize agent plans before execution
|
||||
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
|
||||
- [ ] Support more models/providers (including local) e.g OpenCode (with many providers)
|
||||
- [ ] Remote agent execution via SSH: launch and manage agent teams on remote machines over SSH (stream-json protocol over SSH channel, SFTP-based file monitoring for tasks/inboxes/config)
|
||||
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
|
||||
|
|
@ -322,6 +321,7 @@ pnpm dist # macOS + Windows + Linux
|
|||
- [ ] Monitor agents processes/stats
|
||||
- [ ] Reusable agents with SOUL.md
|
||||
- [ ] Сommunicate via messenger
|
||||
- [ ] SDK to programmatically launch agents
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,9 @@ async function requestJson(baseUrl, pathname, options = {}) {
|
|||
}
|
||||
|
||||
if (payload == null) {
|
||||
throw makeRetryableControlError(`Team control API returned empty or non-JSON response at ${baseUrl}${pathname}`);
|
||||
throw makeRetryableControlError(
|
||||
`Team control API returned empty or non-JSON response at ${baseUrl}${pathname}`
|
||||
);
|
||||
}
|
||||
|
||||
return payload;
|
||||
|
|
@ -170,9 +172,7 @@ function buildLaunchRequest(flags = {}) {
|
|||
...(typeof flags.prompt === 'string' && flags.prompt.trim()
|
||||
? { prompt: flags.prompt.trim() }
|
||||
: {}),
|
||||
...(typeof flags.model === 'string' && flags.model.trim()
|
||||
? { model: flags.model.trim() }
|
||||
: {}),
|
||||
...(typeof flags.model === 'string' && flags.model.trim() ? { model: flags.model.trim() } : {}),
|
||||
...(typeof flags.effort === 'string' && flags.effort.trim()
|
||||
? { effort: flags.effort.trim() }
|
||||
: {}),
|
||||
|
|
@ -228,6 +228,16 @@ function compactRuntimeToolBody(context, flags = {}, fields) {
|
|||
return body;
|
||||
}
|
||||
|
||||
function compactBody(flags = {}, fields) {
|
||||
const body = {};
|
||||
for (const field of fields) {
|
||||
if (flags[field] !== undefined) {
|
||||
body[field] = flags[field];
|
||||
}
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
async function postRuntimeTool(context, flags = {}, toolPath, body) {
|
||||
const baseUrls = resolveControlBaseUrls(context, flags);
|
||||
return requestJsonWithFallback(
|
||||
|
|
@ -276,7 +286,9 @@ async function waitForProvisioningState(baseUrls, teamName, runId, timeoutMs) {
|
|||
}
|
||||
|
||||
const stateLabel =
|
||||
lastProgress && typeof lastProgress.state === 'string' ? ` while in state ${lastProgress.state}` : '';
|
||||
lastProgress && typeof lastProgress.state === 'string'
|
||||
? ` while in state ${lastProgress.state}`
|
||||
: '';
|
||||
throw new Error(`Timed out waiting for team ${teamName} to become ready${stateLabel}`);
|
||||
}
|
||||
|
||||
|
|
@ -328,6 +340,48 @@ async function launchTeam(context, flags = {}) {
|
|||
);
|
||||
}
|
||||
|
||||
async function listTeams(context, flags = {}) {
|
||||
const baseUrls = resolveControlBaseUrls(context, flags);
|
||||
return requestJsonWithFallback(baseUrls, '/api/teams', {
|
||||
timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'] || 10000),
|
||||
});
|
||||
}
|
||||
|
||||
async function getTeam(context, flags = {}) {
|
||||
const baseUrls = resolveControlBaseUrls(context, flags);
|
||||
return requestJsonWithFallback(baseUrls, `/api/teams/${encodeURIComponent(context.teamName)}`, {
|
||||
timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'] || 10000),
|
||||
});
|
||||
}
|
||||
|
||||
async function createTeam(context, flags = {}) {
|
||||
const baseUrls = resolveControlBaseUrls(context, flags);
|
||||
return requestJsonWithFallback(baseUrls, '/api/teams', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
teamName: context.teamName,
|
||||
...compactBody(flags, [
|
||||
'displayName',
|
||||
'description',
|
||||
'color',
|
||||
'members',
|
||||
'cwd',
|
||||
'prompt',
|
||||
'providerId',
|
||||
'providerBackendId',
|
||||
'model',
|
||||
'effort',
|
||||
'fastMode',
|
||||
'limitContext',
|
||||
'skipPermissions',
|
||||
'worktree',
|
||||
'extraCliArgs',
|
||||
]),
|
||||
},
|
||||
timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms'] || 10000),
|
||||
});
|
||||
}
|
||||
|
||||
async function stopTeam(context, flags = {}) {
|
||||
const baseUrls = resolveControlBaseUrls(context, flags);
|
||||
const stopped = await requestJsonWithFallback(
|
||||
|
|
@ -351,7 +405,10 @@ async function stopTeam(context, flags = {}) {
|
|||
|
||||
async function getRuntimeState(context, flags = {}) {
|
||||
const baseUrls = resolveControlBaseUrls(context, flags);
|
||||
return requestJsonWithFallback(baseUrls, `/api/teams/${encodeURIComponent(context.teamName)}/runtime`);
|
||||
return requestJsonWithFallback(
|
||||
baseUrls,
|
||||
`/api/teams/${encodeURIComponent(context.teamName)}/runtime`
|
||||
);
|
||||
}
|
||||
|
||||
async function runtimeBootstrapCheckin(context, flags = {}) {
|
||||
|
|
@ -425,6 +482,9 @@ async function runtimeHeartbeat(context, flags = {}) {
|
|||
}
|
||||
|
||||
module.exports = {
|
||||
listTeams,
|
||||
getTeam,
|
||||
createTeam,
|
||||
launchTeam,
|
||||
stopTeam,
|
||||
getRuntimeState,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
const AGENT_TEAMS_TEAM_TOOL_NAMES = ['team_list', 'team_get', 'team_create'];
|
||||
|
||||
const AGENT_TEAMS_TASK_TOOL_NAMES = [
|
||||
'member_briefing',
|
||||
'task_add_comment',
|
||||
|
|
@ -62,6 +64,11 @@ const AGENT_TEAMS_RUNTIME_TOOL_NAMES = [
|
|||
];
|
||||
|
||||
const AGENT_TEAMS_MCP_TOOL_GROUPS = [
|
||||
{
|
||||
id: 'team',
|
||||
teammateOperational: false,
|
||||
toolNames: AGENT_TEAMS_TEAM_TOOL_NAMES,
|
||||
},
|
||||
{
|
||||
id: 'task',
|
||||
teammateOperational: true,
|
||||
|
|
@ -120,10 +127,12 @@ const AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES = [
|
|||
...AGENT_TEAMS_LEAD_TOOL_NAMES,
|
||||
];
|
||||
|
||||
const AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES =
|
||||
AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES.map((toolName) => `mcp__agent-teams__${toolName}`);
|
||||
const AGENT_TEAMS_NAMESPACED_LEAD_BOOTSTRAP_TOOL_NAMES = AGENT_TEAMS_LEAD_BOOTSTRAP_TOOL_NAMES.map(
|
||||
(toolName) => `mcp__agent-teams__${toolName}`
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
AGENT_TEAMS_TEAM_TOOL_NAMES,
|
||||
AGENT_TEAMS_TASK_TOOL_NAMES,
|
||||
AGENT_TEAMS_LEAD_TOOL_NAMES,
|
||||
AGENT_TEAMS_REVIEW_TOOL_NAMES,
|
||||
|
|
|
|||
360
docs/research/messenger-connectors-uncertainty-pass-29.md
Normal file
360
docs/research/messenger-connectors-uncertainty-pass-29.md
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
# Messenger Connectors - Uncertainty Pass 29
|
||||
|
||||
Date: 2026-04-29
|
||||
Scope: official shared Telegram bot ingress, webhook ACK semantics, offline desktop behavior, and no durable backend plaintext queue
|
||||
|
||||
## Executive Delta
|
||||
|
||||
The next weakest boundary is official bot ingress:
|
||||
|
||||
```text
|
||||
Telegram webhook update
|
||||
official backend
|
||||
desktop live connection
|
||||
durable local turn
|
||||
Telegram webhook ACK
|
||||
```
|
||||
|
||||
The product decision was:
|
||||
|
||||
```text
|
||||
Default official bot, no durable backend plaintext queue.
|
||||
If desktop is offline, be honest and answer offline.
|
||||
Encrypted queue can be added later as advanced reliability mode.
|
||||
```
|
||||
|
||||
This creates a precise reliability contract:
|
||||
|
||||
```text
|
||||
Backend must ACK Telegram only after either:
|
||||
1. desktop durably accepted the plaintext turn locally, or
|
||||
2. backend recorded a redaction-safe offline/blocked decision and attempted or skipped the offline notice by policy, or
|
||||
3. this is a duplicate already completed update.
|
||||
```
|
||||
|
||||
Do not use Telegram webhook retry as the queue. It is operationally noisy, finite, and hard to reason about.
|
||||
|
||||
## Source Facts Rechecked
|
||||
|
||||
Telegram official facts checked on 2026-04-29:
|
||||
|
||||
- `setWebhook` sends HTTPS POST updates to our URL.
|
||||
- Telegram repeats webhook delivery after a non-2xx response and eventually gives up after a reasonable number of attempts.
|
||||
- `secret_token` can be configured so Telegram includes `X-Telegram-Bot-Api-Secret-Token`.
|
||||
- `max_connections` can be 1-100 and defaults to 40.
|
||||
- `drop_pending_updates` can drop pending updates.
|
||||
- `getWebhookInfo` exposes `pending_update_count` and last error fields.
|
||||
- `getUpdates` confirms updates by calling with offset greater than the previous `update_id`.
|
||||
- Telegram stores incoming updates until received, but not longer than 24 hours.
|
||||
- `getUpdates` cannot be used while webhook is set.
|
||||
- Bot API calls made directly in the webhook response do not return a result to us.
|
||||
|
||||
Sources:
|
||||
|
||||
- https://core.telegram.org/bots/api#setwebhook
|
||||
- https://core.telegram.org/bots/api#getwebhookinfo
|
||||
- https://core.telegram.org/bots/api#getting-updates
|
||||
- https://core.telegram.org/bots/api#making-requests-when-getting-updates
|
||||
- https://core.telegram.org/bots/faq
|
||||
|
||||
Local code facts:
|
||||
|
||||
- Existing `HttpServer` binds to localhost by default and serves local app HTTP routes through Fastify.
|
||||
- Existing browser mode uses SSE from local server to renderer.
|
||||
- There is no current cloud/backend persistent relay layer for messenger traffic.
|
||||
- The app already has a usable local event idea, but messenger official mode needs a new outbound desktop-to-cloud control connection, not the existing local HTTP server.
|
||||
|
||||
## 1. The ACK Problem
|
||||
|
||||
If the backend returns non-2xx to Telegram because desktop is offline, Telegram retries. That sounds like a queue, but it is a bad queue:
|
||||
|
||||
- plaintext stays in Telegram's pending delivery mechanism, not under our product semantics;
|
||||
- retries can repeat the same update while the user keeps typing;
|
||||
- `pending_update_count` can grow and hide real bugs;
|
||||
- retries are finite;
|
||||
- update retention has an upper bound;
|
||||
- backend may send duplicate offline notices unless it has its own idempotency state.
|
||||
|
||||
If the backend returns 2xx before desktop has durably accepted the update, the lead message can be lost forever in official mode because we intentionally do not keep plaintext.
|
||||
|
||||
Therefore ACK timing is the core ingress invariant.
|
||||
|
||||
## 2. Recommended Backend Ingress State Machine
|
||||
|
||||
Backend should persist only redaction-safe metadata before side effects:
|
||||
|
||||
```ts
|
||||
type OfficialIngressReceipt = {
|
||||
receiptId: string;
|
||||
provider: 'telegram';
|
||||
botScope: 'official';
|
||||
updateId: number;
|
||||
providerMessageKey: string;
|
||||
routeId: string | null;
|
||||
routeGeneration: number | null;
|
||||
textHash: string | null;
|
||||
fromUserHash: string | null;
|
||||
chatIdHash: string;
|
||||
messageThreadId: number | null;
|
||||
status:
|
||||
| 'received'
|
||||
| 'route_missing'
|
||||
| 'desktop_claim_started'
|
||||
| 'desktop_accepted'
|
||||
| 'desktop_acceptance_unknown'
|
||||
| 'offline_notice_started'
|
||||
| 'offline_notice_sent'
|
||||
| 'offline_notice_ambiguous'
|
||||
| 'acknowledged'
|
||||
| 'failed_terminal';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
```
|
||||
|
||||
State rules:
|
||||
|
||||
```text
|
||||
received -> desktop_claim_started -> desktop_accepted -> acknowledged
|
||||
received -> offline_notice_started -> offline_notice_sent -> acknowledged
|
||||
received -> offline_notice_started -> offline_notice_ambiguous -> acknowledged
|
||||
received -> route_missing -> acknowledged
|
||||
duplicate acknowledged -> acknowledged
|
||||
```
|
||||
|
||||
Important: `offline_notice_ambiguous` still ACKs the webhook. It is better to possibly miss the offline notice than to auto-send duplicate notices or keep Telegram retrying.
|
||||
|
||||
## 3. Desktop Claim Protocol
|
||||
|
||||
Official mode needs a desktop-initiated persistent connection:
|
||||
|
||||
```text
|
||||
desktop -> backend: connect(route subscriptions, install id, session key, capabilities)
|
||||
backend -> desktop: inbound plaintext turn
|
||||
desktop -> backend: accepted_local(internalTurnId, providerMessageKey, localMessageId)
|
||||
backend -> Telegram: 2xx webhook ACK
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Backend forwards plaintext only to an already-authenticated active desktop connection.
|
||||
- Desktop must persist the turn locally before returning `accepted_local`.
|
||||
- Desktop dedupes by `providerMessageKey` and returns the existing local acceptance if the backend retries delivery.
|
||||
- Backend does not store plaintext after the request handler scope.
|
||||
- Backend stores only hashes and receipt state.
|
||||
- If no desktop session can accept within a short timeout, backend goes to offline policy.
|
||||
|
||||
Suggested timeout:
|
||||
|
||||
```text
|
||||
2-4 seconds for desktop accepted_local
|
||||
then offline response or offline status
|
||||
```
|
||||
|
||||
This keeps webhook handlers bounded and avoids Telegram retry storms.
|
||||
|
||||
## 4. Crash Matrix
|
||||
|
||||
Critical cases:
|
||||
|
||||
- Backend crashes before persisting receipt.
|
||||
- Telegram retries; safe, because no side effect happened.
|
||||
- Backend persists receipt, crashes before desktop forward.
|
||||
- Telegram retries; backend can process again from `received`.
|
||||
- Backend forwards plaintext to desktop, crashes before desktop ACK.
|
||||
- Telegram retries; desktop dedupe by `providerMessageKey` prevents duplicate local turn.
|
||||
- Desktop persists local turn, ACK to backend is lost.
|
||||
- Telegram retries; backend redelivers, desktop returns existing `accepted_local`.
|
||||
- Backend records `desktop_accepted`, crashes before HTTP 2xx.
|
||||
- Telegram retries; backend sees completed receipt and returns 2xx without redelivering.
|
||||
- Desktop offline.
|
||||
- Backend records offline decision and ACKs Telegram after offline policy.
|
||||
- Offline notice `sendMessage` succeeds but backend crashes before marking success.
|
||||
- On retry, backend must not blindly resend. Mark `offline_notice_ambiguous`, ACK, show support diagnostics.
|
||||
|
||||
## 5. Offline Policy
|
||||
|
||||
For default official MVP:
|
||||
|
||||
```text
|
||||
No desktop live acceptance = no local delivery.
|
||||
```
|
||||
|
||||
Then choose one of two offline UX policies:
|
||||
|
||||
1. Send a short Telegram offline notice.
|
||||
2. ACK silently and rely on topic status / setup UI.
|
||||
|
||||
I recommend a short offline notice, but only through the same provider outbox ambiguity policy from pass 42.
|
||||
|
||||
Example behavior:
|
||||
|
||||
```text
|
||||
Agent Teams desktop is offline. Open the app on the connected computer and send the message again.
|
||||
```
|
||||
|
||||
Do not store the lead's plaintext for later replay.
|
||||
|
||||
## 6. Why Not Use Telegram As The Queue
|
||||
|
||||
Top risks:
|
||||
|
||||
- Telegram will retry on non-2xx, but the retry schedule and final give-up behavior are provider-controlled.
|
||||
- `pending_update_count` becomes an operational failure queue with plaintext updates we cannot inspect safely.
|
||||
- Once we finally ACK, Telegram considers the update handled, even if desktop state is not coherent.
|
||||
- If webhook is reconfigured with `drop_pending_updates`, lead messages can be intentionally discarded.
|
||||
- If the app is offline for more than Telegram retention, messages are lost anyway.
|
||||
|
||||
This conflicts with the product's "honest offline" behavior.
|
||||
|
||||
## 7. Official Mode Privacy Story
|
||||
|
||||
The honest statement:
|
||||
|
||||
```text
|
||||
Official shared bot backend sees message plaintext transiently while handling Telegram delivery.
|
||||
It does not durably store plaintext in MVP.
|
||||
It stores redaction-safe delivery metadata and hashes for dedupe, abuse prevention, and diagnostics.
|
||||
```
|
||||
|
||||
Not honest:
|
||||
|
||||
```text
|
||||
Our backend never sees messages.
|
||||
```
|
||||
|
||||
That statement is only true for private own-bot local polling mode, not official shared bot mode.
|
||||
|
||||
## 8. Own Bot Contrast
|
||||
|
||||
Own bot mode is much simpler for ingress:
|
||||
|
||||
```text
|
||||
desktop getUpdates
|
||||
desktop durable local turn
|
||||
desktop confirms offset
|
||||
```
|
||||
|
||||
Because Telegram `getUpdates` confirms by offset, desktop can persist locally before advancing offset. That is a better privacy and reliability story for users who want it.
|
||||
|
||||
But own bot mode is less convenient because the user must create/configure a bot.
|
||||
|
||||
## 9. Desktop To Backend Transport Options
|
||||
|
||||
1. Persistent WebSocket from desktop to backend - 🎯 8 🛡️ 8 🧠 7 - approx `1600-3600` changed LOC.
|
||||
Best default for official mode. Full duplex, explicit ACK messages, connection leases, heartbeats, route subscriptions.
|
||||
|
||||
2. Server-Sent Events from backend to desktop plus HTTPS POST ACKs - 🎯 7 🛡️ 7 🧠 6 - approx `1300-3000` changed LOC.
|
||||
Simpler in some networks, but ACK correlation and reconnect handling are more awkward.
|
||||
|
||||
3. Desktop polling backend every N seconds - 🎯 5 🛡️ 5 🧠 4 - approx `700-1800` changed LOC.
|
||||
Poor fit for no plaintext queue because backend would need to hold plaintext or lead messages would be missed between polls.
|
||||
|
||||
Recommendation:
|
||||
|
||||
```text
|
||||
Use WebSocket-like persistent desktop claim channel for official mode.
|
||||
Do not add a package decision until package versions can be verified in an unrestricted network environment.
|
||||
```
|
||||
|
||||
## 10. Multi-Desktop And Lease Policy
|
||||
|
||||
If the same user connects the same official route from multiple desktops:
|
||||
|
||||
```text
|
||||
Only one active receiver lease may own inbound plaintext delivery.
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
1. Single primary device per route - 🎯 8 🛡️ 8 🧠 5 - approx `600-1400` changed LOC.
|
||||
Recommended for MVP. Simple and prevents split-brain delivery.
|
||||
|
||||
2. Fan out to all active desktops and accept first durable ACK - 🎯 6 🛡️ 6 🧠 7 - approx `1200-2600` changed LOC.
|
||||
Can duplicate local inboxes and confuse reply ownership.
|
||||
|
||||
3. Per-team device assignment - 🎯 7 🛡️ 8 🧠 7 - approx `1400-3200` changed LOC.
|
||||
Useful later for power users, too much for MVP.
|
||||
|
||||
## 11. Security Requirements
|
||||
|
||||
Minimum official ingress controls:
|
||||
|
||||
- Verify `X-Telegram-Bot-Api-Secret-Token`.
|
||||
- Use a secret webhook path as defense in depth.
|
||||
- Reject updates that do not match expected bot id/account binding.
|
||||
- Use `allowed_updates` to narrow update surface.
|
||||
- Persist update id/provider message key dedupe.
|
||||
- Rate-limit offline notices by chat/topic.
|
||||
- HMAC/hash user ids and chat ids in backend logs.
|
||||
- Do not log plaintext update payloads.
|
||||
- Encrypt desktop-backend transport.
|
||||
- Rotate desktop session tokens.
|
||||
|
||||
## 12. Test Matrix
|
||||
|
||||
Tests should simulate:
|
||||
|
||||
- valid webhook with active desktop accepted;
|
||||
- duplicate webhook update after accepted;
|
||||
- backend crash before receipt write;
|
||||
- backend crash after receipt write;
|
||||
- backend crash after desktop forward;
|
||||
- desktop accepted locally but ACK lost;
|
||||
- backend accepted desktop ACK but HTTP 2xx lost;
|
||||
- desktop offline;
|
||||
- offline notice success;
|
||||
- offline notice timeout after request start;
|
||||
- webhook secret mismatch;
|
||||
- route missing;
|
||||
- route disabled;
|
||||
- topic deleted;
|
||||
- bot permission lost;
|
||||
- two desktop sessions racing;
|
||||
- webhook max_connections concurrent deliveries out of order;
|
||||
- `drop_pending_updates` during reconnect;
|
||||
- old update after allowed_updates change;
|
||||
- backend store unavailable;
|
||||
- desktop reconnect while webhook is in-flight.
|
||||
|
||||
Pass criterion:
|
||||
|
||||
```text
|
||||
No plaintext is durably stored by official backend.
|
||||
No Telegram update is ACKed as handled before either desktop durable acceptance or an explicit offline/blocked decision.
|
||||
No duplicate local turns for the same providerMessageKey.
|
||||
No duplicate offline notice unless user/support explicitly chooses duplicate send.
|
||||
```
|
||||
|
||||
## 13. Top 3 Overall Options
|
||||
|
||||
1. Synchronous desktop claim + redaction-safe ingress receipt + offline notice outbox - 🎯 8 🛡️ 8 🧠 8 - approx `2500-6000` changed LOC.
|
||||
Recommended official MVP. It matches "no durable backend plaintext queue" and gives deterministic failure states.
|
||||
|
||||
2. Encrypted backend queue for later desktop replay - 🎯 7 🛡️ 9 🧠 9 - approx `3500-8000` changed LOC.
|
||||
Better reliability, but bigger system. Backend still sees plaintext transiently from Telegram before encrypting.
|
||||
|
||||
3. Non-2xx webhook until desktop online, using Telegram retries as queue - 🎯 4 🛡️ 4 🧠 4 - approx `800-2000` changed LOC.
|
||||
Not recommended. It is brittle, provider-controlled, and creates operational backlog.
|
||||
|
||||
## 14. Decision Update
|
||||
|
||||
Official shared bot MVP should implement:
|
||||
|
||||
```text
|
||||
Telegram webhook
|
||||
-> backend redaction-safe receipt
|
||||
-> if desktop active: synchronous durable desktop claim
|
||||
-> if accepted: ACK Telegram
|
||||
-> if not accepted: offline notice policy, then ACK Telegram
|
||||
```
|
||||
|
||||
Own bot mode remains:
|
||||
|
||||
```text
|
||||
desktop long polling
|
||||
-> local durable turn
|
||||
-> advance update offset
|
||||
```
|
||||
|
||||
This keeps the default UX simple while making the privacy/reliability tradeoff explicit instead of accidental.
|
||||
645
docs/research/messenger-connectors-uncertainty-pass-30.md
Normal file
645
docs/research/messenger-connectors-uncertainty-pass-30.md
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
# Messenger Connectors - Uncertainty Pass 30
|
||||
|
||||
Date: 2026-04-29
|
||||
Scope: Telegram media and attachments for official shared bot mode, own-bot mode, inbox persistence, and no durable backend plaintext queue
|
||||
|
||||
## Executive Delta
|
||||
|
||||
The lowest-confidence boundary after webhook ACK timing is media:
|
||||
|
||||
```text
|
||||
Telegram message with photo/document/voice
|
||||
official backend receives update
|
||||
backend may need bot token to fetch file bytes
|
||||
desktop may be offline
|
||||
local app currently persists attachments only for live lead messages
|
||||
agent reply may need to reference or send files back
|
||||
```
|
||||
|
||||
This is not just a file download problem. It changes the privacy story.
|
||||
|
||||
For official shared bot mode, the backend receives the update and can technically fetch Telegram files with the official bot token. Even if we do not store plaintext or media durably, the backend is in the transient data path. That is acceptable only if the product copy is precise:
|
||||
|
||||
```text
|
||||
Default official bot:
|
||||
- easiest setup
|
||||
- no durable backend plaintext/media queue
|
||||
- backend may transiently process messages while routing them
|
||||
- if desktop is offline, we honestly say offline
|
||||
```
|
||||
|
||||
Private own-bot mode is the clean privacy mode:
|
||||
|
||||
```text
|
||||
Own bot:
|
||||
- token stays in desktop
|
||||
- desktop polls or receives webhooks directly when online
|
||||
- backend does not receive lead messages or media
|
||||
- offline reliability is lower unless user enables a separate relay/queue
|
||||
```
|
||||
|
||||
⚠️ Recommendation update: launch official shared bot as text-first. Treat Telegram media as metadata-only/unsupported in the first official MVP. Add private own-bot media support before official shared bot media streaming if privacy is a core selling point.
|
||||
|
||||
## Source Facts Rechecked
|
||||
|
||||
Telegram official facts checked on 2026-04-29:
|
||||
|
||||
- Bot API is token-based. API calls are made to `https://api.telegram.org/bot<token>/METHOD_NAME`.
|
||||
- Webhook responses can call a Bot API method inline, but Telegram does not return the method result to us in that webhook response.
|
||||
- Incoming updates are stored by Telegram until received, but not longer than 24 hours.
|
||||
- `Update.message` can be any kind of message, including text, photo, sticker, and more.
|
||||
- `getFile` returns a `File` object and prepares a file for download.
|
||||
- File download URL shape is `https://api.telegram.org/file/bot<token>/<file_path>`.
|
||||
- Telegram guarantees that the file download link is valid for at least 1 hour.
|
||||
- Standard cloud Bot API download limit is 20 MB.
|
||||
- Local Bot API server can download without a size limit, upload up to 2000 MB, and can return a local `file_path`.
|
||||
- `sendPhoto` supports `message_thread_id` and `direct_messages_topic_id`; uploaded photos are limited to 10 MB.
|
||||
- `sendDocument` supports `message_thread_id` and `direct_messages_topic_id`; uploaded files are currently up to 50 MB.
|
||||
- `sendMediaGroup` sends albums of 2-10 media items.
|
||||
- `createForumTopic` can create a topic in a forum supergroup or a private chat with a user.
|
||||
- Bot API 9.6 Managed Bots expose `getManagedBotToken`; the manager bot can fetch the managed bot token.
|
||||
|
||||
Sources:
|
||||
|
||||
- https://core.telegram.org/bots/api#making-requests
|
||||
- https://core.telegram.org/bots/api#making-requests-when-getting-updates
|
||||
- https://core.telegram.org/bots/api#getting-updates
|
||||
- https://core.telegram.org/bots/api#file
|
||||
- https://core.telegram.org/bots/api#getfile
|
||||
- https://core.telegram.org/bots/api#using-a-local-bot-api-server
|
||||
- https://core.telegram.org/bots/api#sendphoto
|
||||
- https://core.telegram.org/bots/api#senddocument
|
||||
- https://core.telegram.org/bots/api#sendmediagroup
|
||||
- https://core.telegram.org/bots/api#createforumtopic
|
||||
- https://core.telegram.org/bots/api#getmanagedbottoken
|
||||
|
||||
Local code facts:
|
||||
|
||||
- `AttachmentPayload` contains base64 data and metadata.
|
||||
- `AttachmentMeta` is persisted on message rows and may include a local file path.
|
||||
- `TeamAttachmentStore` writes files under app data `attachments/{teamName}/{messageId}` and stores `_index.json`.
|
||||
- `TeamAttachmentStore` sanitizes path segments and stored filenames.
|
||||
- Main-process IPC currently accepts only these message attachment MIME types: PNG, JPEG, GIF, WebP, PDF, and plain text.
|
||||
- Main-process IPC currently limits message attachments to 5 files, 10 MB per file, and 20 MB total.
|
||||
- `handleSendMessage` allows attachments only when sending to the live team lead.
|
||||
- If stdin delivery fails after attachments were requested, the current code fails instead of silently dropping attachments.
|
||||
- The inbox path is described as offline lead or regular members with no attachment support.
|
||||
- OpenCode secondary runtime delivery marks attachment messages as terminal failure because attachments are not supported for secondary runtime.
|
||||
- Renderer composer blocks attachments for cross-team messages, non-lead recipients, and offline teams.
|
||||
|
||||
Implication:
|
||||
|
||||
```text
|
||||
Current app has a useful local attachment store,
|
||||
but messenger media cannot safely reuse offline inbox delivery until we add
|
||||
a durable provider-neutral media acceptance protocol.
|
||||
```
|
||||
|
||||
## 1. Why Media Is Harder Than Text
|
||||
|
||||
Text flow can be bounded:
|
||||
|
||||
```text
|
||||
backend receives plaintext text
|
||||
desktop accepts locally
|
||||
backend ACKs Telegram
|
||||
backend forgets plaintext
|
||||
```
|
||||
|
||||
Media flow needs at least one more side effect:
|
||||
|
||||
```text
|
||||
backend receives file_id/file_unique_id/caption
|
||||
backend calls getFile
|
||||
backend downloads bytes using URL that embeds bot token
|
||||
desktop writes bytes to local attachment store
|
||||
desktop commits message row with attachment metadata
|
||||
backend ACKs Telegram
|
||||
```
|
||||
|
||||
Every step can fail independently.
|
||||
|
||||
The dangerous half-states are:
|
||||
|
||||
- backend ACKs Telegram, but desktop never wrote the file;
|
||||
- desktop wrote the file, but message row did not commit;
|
||||
- message row committed, but attachment file write failed;
|
||||
- backend downloaded media but desktop disconnected;
|
||||
- duplicate webhook retries download the same media multiple times;
|
||||
- media group arrives as multiple updates and only some items are accepted;
|
||||
- file is too large for Telegram cloud `getFile`;
|
||||
- file link expires while desktop is offline;
|
||||
- file_id is stored durably and becomes a capability to fetch content later with the bot token;
|
||||
- provider MIME/type says one thing, actual bytes are another.
|
||||
|
||||
This is why media should not be part of the default official MVP unless it has its own state machine.
|
||||
|
||||
## 2. Privacy Reality
|
||||
|
||||
There are three privacy tiers.
|
||||
|
||||
### Tier A: official shared bot, text-only
|
||||
|
||||
```text
|
||||
Backend transiently sees message text.
|
||||
Backend stores only redaction-safe receipts and hashes.
|
||||
No media bytes pass through backend because media is unsupported.
|
||||
```
|
||||
|
||||
Privacy story:
|
||||
|
||||
```text
|
||||
Simple default connection.
|
||||
No durable backend plaintext queue.
|
||||
Not end-to-end private from our backend.
|
||||
```
|
||||
|
||||
### Tier B: official shared bot, ephemeral media streaming
|
||||
|
||||
```text
|
||||
Backend transiently sees file metadata and file bytes.
|
||||
Backend does not write bytes to disk.
|
||||
Desktop must be online and must commit the attachment locally.
|
||||
```
|
||||
|
||||
Privacy story:
|
||||
|
||||
```text
|
||||
Convenient, but backend is a transient processor for media.
|
||||
No durable backend media store.
|
||||
```
|
||||
|
||||
### Tier C: own bot, local token
|
||||
|
||||
```text
|
||||
Desktop holds token.
|
||||
Desktop downloads Telegram files directly.
|
||||
Backend never receives message text or media.
|
||||
```
|
||||
|
||||
Privacy story:
|
||||
|
||||
```text
|
||||
Best privacy.
|
||||
More setup.
|
||||
Works only while desktop app or local service is running, unless user adds their own hosting.
|
||||
```
|
||||
|
||||
Managed Bots do not eliminate token exposure if our manager bot is the manager. Telegram added `getManagedBotToken`, and the official docs say the token can be fetched by the manager bot. Therefore, Managed Bots are a UX feature, not a clean no-token-access privacy feature for us.
|
||||
|
||||
## 3. Treat file_id As Sensitive
|
||||
|
||||
Telegram `file_id` is not the file bytes, but it is not harmless metadata.
|
||||
|
||||
Reason:
|
||||
|
||||
```text
|
||||
file_id + bot token -> getFile -> download URL -> file bytes
|
||||
```
|
||||
|
||||
Therefore:
|
||||
|
||||
- do not store raw `file_id` in durable official backend receipts unless encrypted;
|
||||
- do not put raw `file_id` in logs;
|
||||
- do not expose raw `file_id` to renderer unless the renderer needs it for an explicit action;
|
||||
- prefer local desktop storage of raw provider file ids only after user acceptance;
|
||||
- store `file_unique_id` only for dedupe if needed, but remember it cannot download or reuse the file;
|
||||
- store HMACs for backend idempotency where possible.
|
||||
|
||||
Suggested receipt fields:
|
||||
|
||||
```ts
|
||||
type ProviderMediaReceipt = {
|
||||
provider: 'telegram';
|
||||
scope: 'official' | 'own_bot';
|
||||
updateId: number;
|
||||
providerMessageKey: string;
|
||||
providerMediaKeyHash: string;
|
||||
providerFileUniqueIdHash: string | null;
|
||||
providerFileIdEncrypted?: string;
|
||||
mediaKind: 'photo' | 'document' | 'voice' | 'audio' | 'video' | 'animation' | 'sticker' | 'unknown';
|
||||
declaredMimeType: string | null;
|
||||
declaredSizeBytes: number | null;
|
||||
captionHash: string | null;
|
||||
status:
|
||||
| 'received'
|
||||
| 'unsupported_policy'
|
||||
| 'desktop_claim_started'
|
||||
| 'desktop_media_committed'
|
||||
| 'desktop_text_only_committed'
|
||||
| 'offline_notice_started'
|
||||
| 'offline_notice_sent'
|
||||
| 'offline_notice_ambiguous'
|
||||
| 'acknowledged'
|
||||
| 'failed_terminal';
|
||||
};
|
||||
```
|
||||
|
||||
For default official mode, omit `providerFileIdEncrypted` entirely.
|
||||
|
||||
## 4. Official MVP Policy
|
||||
|
||||
For official shared bot v1:
|
||||
|
||||
```text
|
||||
Text, captions, commands:
|
||||
- support
|
||||
|
||||
Photo/document/voice/audio/video/sticker:
|
||||
- do not download
|
||||
- route caption text if present
|
||||
- include local metadata placeholder only if useful
|
||||
- tell lead in Telegram that attachments are not supported yet or require desktop online
|
||||
```
|
||||
|
||||
The system message should be explicit but not noisy:
|
||||
|
||||
```text
|
||||
I received an attachment, but this connection currently supports text only.
|
||||
Please send the key details as text, or connect a private bot for local file handling.
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- If a message has `caption`, deliver caption as the text turn.
|
||||
- If a message has media and no caption, create a local event only if desktop is online and can persist a metadata-only placeholder.
|
||||
- If desktop is offline, send one offline/unsupported notice and ACK Telegram.
|
||||
- Deduplicate unsupported notices by `providerMessageKey`.
|
||||
- Do not call `getFile` in official MVP.
|
||||
- Do not store `file_id`.
|
||||
|
||||
This keeps the first version honest and avoids a half-built media pipeline.
|
||||
|
||||
## 5. Own-Bot Media Policy
|
||||
|
||||
Own-bot mode can support media earlier because the desktop has the token.
|
||||
|
||||
```text
|
||||
desktop receives update via getUpdates or local webhook
|
||||
desktop calls getFile directly
|
||||
desktop downloads bytes directly
|
||||
desktop writes TeamAttachmentStore
|
||||
desktop writes message row
|
||||
desktop sends ACK/offset after local commit
|
||||
```
|
||||
|
||||
The exact update intake can be:
|
||||
|
||||
- desktop long polling with `getUpdates`;
|
||||
- local webhook only if user has a reachable tunnel or local Bot API server;
|
||||
- later, optional user-hosted relay.
|
||||
|
||||
For consumer desktop UX, long polling is simpler and more private:
|
||||
|
||||
```text
|
||||
No inbound public port.
|
||||
No server token storage.
|
||||
Works while app is open.
|
||||
Telegram can still be used from phone as the client UI.
|
||||
```
|
||||
|
||||
Limitations:
|
||||
|
||||
- if desktop is asleep or app closed, no processing;
|
||||
- Telegram retains updates only up to its limits;
|
||||
- if the user also runs the same token elsewhere, `getUpdates` offset/webhook conflicts can appear;
|
||||
- if webhook is set for the bot, `getUpdates` will not work until webhook is deleted.
|
||||
|
||||
## 6. Ephemeral Official Media Streaming
|
||||
|
||||
If we later support official shared bot media without durable backend media queue, use a strict active-desktop stream:
|
||||
|
||||
```text
|
||||
Telegram webhook update
|
||||
backend receipt persisted with hashes only
|
||||
backend checks desktop session capability
|
||||
backend calls getFile
|
||||
backend streams file bytes to desktop over existing desktop connection
|
||||
desktop writes temp file
|
||||
desktop validates size/hash/MIME
|
||||
desktop atomically moves into TeamAttachmentStore
|
||||
desktop writes message row
|
||||
desktop returns accepted_local_media
|
||||
backend ACKs Telegram
|
||||
```
|
||||
|
||||
Backend rules:
|
||||
|
||||
- stream only to an already authenticated desktop route session;
|
||||
- do not write bytes to disk;
|
||||
- limit file size before download using Telegram metadata when present;
|
||||
- enforce hard byte counters during stream;
|
||||
- abort stream if desktop disconnects;
|
||||
- never log filename, file_id, or file_path;
|
||||
- do not retry file downloads after request scope unless encrypted queue is enabled.
|
||||
|
||||
Desktop rules:
|
||||
|
||||
- write to a temp file outside final attachment path;
|
||||
- compute SHA-256 while streaming;
|
||||
- sniff magic bytes for supported types;
|
||||
- verify final byte count;
|
||||
- atomically move into local attachment store;
|
||||
- only then commit message row;
|
||||
- dedupe by `providerMessageKey + providerMediaPartKey`;
|
||||
- return the existing local acceptance for duplicate delivery.
|
||||
|
||||
This needs a new storage API. Current `TeamAttachmentStore.saveAttachments` expects base64 payloads. Streaming media should not base64 all bytes through IPC.
|
||||
|
||||
Suggested extension:
|
||||
|
||||
```ts
|
||||
interface AttachmentContentStore {
|
||||
saveBase64MessageAttachments(input: SaveBase64AttachmentsInput): Promise<SavedAttachment[]>;
|
||||
saveStreamedMessageAttachment(input: SaveStreamedAttachmentInput): Promise<SavedAttachment>;
|
||||
getMessageAttachmentFiles(input: GetAttachmentFilesInput): Promise<AttachmentFileData[]>;
|
||||
}
|
||||
```
|
||||
|
||||
The Telegram adapter should depend on this port, not on `TeamAttachmentStore` directly.
|
||||
|
||||
## 7. Inbox Integration Model
|
||||
|
||||
Current inbox rows can carry `AttachmentMeta[]`, but the inbox path does not guarantee bytes exist. Messenger media needs a stronger invariant:
|
||||
|
||||
```text
|
||||
An inbox/message row may reference an attachment only after local bytes are committed,
|
||||
unless the attachment is explicitly marked as metadata-only/unsupported.
|
||||
```
|
||||
|
||||
Add a provider-neutral attachment state:
|
||||
|
||||
```ts
|
||||
type MessengerAttachmentState =
|
||||
| 'available_local'
|
||||
| 'metadata_only'
|
||||
| 'unsupported_policy'
|
||||
| 'too_large'
|
||||
| 'download_failed'
|
||||
| 'expired'
|
||||
| 'blocked_security';
|
||||
|
||||
type MessengerAttachmentMeta = AttachmentMeta & {
|
||||
state: MessengerAttachmentState;
|
||||
provider: 'telegram' | 'whatsapp' | 'discord';
|
||||
providerKind: string;
|
||||
providerMessageKey: string;
|
||||
providerMediaPartKey: string;
|
||||
caption?: string;
|
||||
checksumSha256?: string;
|
||||
localCommittedAt?: string;
|
||||
};
|
||||
```
|
||||
|
||||
Do not overload `AttachmentMeta.filePath` absence as "unsupported". It already means metadata-only in comments, but messenger needs typed status for UI, retries, and support.
|
||||
|
||||
## 8. Media Group Edge Cases
|
||||
|
||||
Telegram albums arrive as multiple messages with a shared grouping concept. Do not assume one update equals one logical user turn.
|
||||
|
||||
Policy:
|
||||
|
||||
- collect album parts in a short local aggregation window, for example 800-1500 ms;
|
||||
- if some parts are unsupported, deliver one consolidated turn with mixed attachment states;
|
||||
- dedupe every part independently;
|
||||
- do not block a text caption forever waiting for missing album parts;
|
||||
- if the same album has multiple captions, preserve each caption near its part in the local model;
|
||||
- if aggregation times out, commit what is available and mark late duplicates as follow-up parts.
|
||||
|
||||
For MVP text-only official mode:
|
||||
|
||||
- do not download album files;
|
||||
- aggregate captions and unsupported media counts;
|
||||
- send at most one notice per album.
|
||||
|
||||
## 9. Outbound Media From Agent To Telegram
|
||||
|
||||
Outbound media is easier only if the file already exists locally and the desktop is online.
|
||||
|
||||
Flow:
|
||||
|
||||
```text
|
||||
agent/tool creates reply with attachment reference
|
||||
desktop validates local file and policy
|
||||
desktop sends request to provider adapter
|
||||
official adapter uploads via backend
|
||||
own-bot adapter uploads directly
|
||||
provider returns message ids
|
||||
local delivery ledger marks sent
|
||||
```
|
||||
|
||||
Official shared bot outbound media privacy:
|
||||
|
||||
```text
|
||||
If backend uploads the file to Telegram, backend transiently sees file bytes.
|
||||
```
|
||||
|
||||
That is acceptable only under the same "transient processor, no durable media queue" contract.
|
||||
|
||||
MVP:
|
||||
|
||||
- outbound official: text only;
|
||||
- outbound own-bot: optionally support local photos/documents after inbound media is solid;
|
||||
- never let an agent silently send local files to Telegram without explicit policy gates.
|
||||
|
||||
## 10. Security Rules
|
||||
|
||||
Minimum rules before any media bytes are supported:
|
||||
|
||||
- allowlist MIME families by provider mode;
|
||||
- enforce byte limits before and during download;
|
||||
- store original filename only after sanitization;
|
||||
- keep provider filename as untrusted display text;
|
||||
- never use provider filename as path;
|
||||
- sniff magic bytes for images/PDF/text where possible;
|
||||
- reject archives in MVP;
|
||||
- reject executable types;
|
||||
- do not auto-open downloaded files;
|
||||
- do not feed binary content to the model unless the app explicitly supports that type;
|
||||
- captions and filenames are untrusted user input, not system instructions;
|
||||
- strip or ignore path-like names;
|
||||
- rate-limit media downloads per route;
|
||||
- record redaction-safe diagnostics for failed media;
|
||||
- design future malware scanning as an optional port, not hardcoded vendor logic.
|
||||
|
||||
For text extraction:
|
||||
|
||||
- plain text files can be included only after encoding validation and size cap;
|
||||
- PDFs should be attached as model document blocks only when provider/runtime supports them;
|
||||
- voice transcription should be a separate explicit feature, preferably local-first if privacy matters.
|
||||
|
||||
## 11. Failure Matrix
|
||||
|
||||
Critical cases:
|
||||
|
||||
- Update has media but no text.
|
||||
- Official MVP: unsupported notice, metadata-only local event if desktop online.
|
||||
- Update has media plus caption.
|
||||
- Official MVP: deliver caption and mention unsupported attachment count.
|
||||
- Duplicate webhook after unsupported notice.
|
||||
- Return completed receipt, do not resend notice.
|
||||
- Duplicate webhook after desktop local commit.
|
||||
- Desktop returns existing local message id.
|
||||
- Backend calls `getFile`, desktop disconnects before any bytes.
|
||||
- Abort stream, offline/unsupported policy, ACK according to receipt state.
|
||||
- Backend streams bytes, desktop crashes before commit.
|
||||
- Duplicate webhook can retry only if backend has not ACKed yet.
|
||||
- Desktop commits file, ACK to backend is lost.
|
||||
- Duplicate webhook redelivers, desktop dedupes and returns existing acceptance.
|
||||
- File exceeds Telegram cloud download limit.
|
||||
- Mark too_large; suggest user resend as text or use own bot/local server mode later.
|
||||
- File download URL expires.
|
||||
- In official no-queue mode, do not attempt later replay. Mark expired if it happens in active stream.
|
||||
- Provider MIME lies.
|
||||
- Sniff bytes, reject if mismatch is dangerous.
|
||||
- Filename is `../../x` or has control chars.
|
||||
- Sanitize and preserve original only as escaped display text if needed.
|
||||
- Media group partially arrives.
|
||||
- Commit consolidated partial turn with per-part states.
|
||||
- Backend crashes after downloading media but before desktop commit.
|
||||
- No backend disk means media is lost; webhook retry may redownload if not ACKed.
|
||||
- Backend crashes after desktop commit but before HTTP 2xx.
|
||||
- Telegram retries; backend uses receipt and desktop dedupe to ACK.
|
||||
|
||||
## 12. Top 3 Options
|
||||
|
||||
### Option 1 - Official text-only MVP, media metadata/notice, own-bot media later
|
||||
|
||||
🎯 9 🛡️ 9 🧠 4
|
||||
|
||||
Approx changed LOC: 700-1800.
|
||||
|
||||
What it means:
|
||||
|
||||
- official shared bot supports text and captions;
|
||||
- official shared bot does not call `getFile`;
|
||||
- official shared bot does not store `file_id`;
|
||||
- media-only messages get one clear unsupported notice;
|
||||
- local UI can show "attachment received, not imported" metadata only when desktop is online;
|
||||
- own-bot adapter is the first place where real media support can land.
|
||||
|
||||
Why this is best now:
|
||||
|
||||
- aligns with no durable backend plaintext/media queue;
|
||||
- avoids token/file privacy ambiguity;
|
||||
- matches current app constraints where attachments require live lead;
|
||||
- minimizes risk of partial file delivery bugs;
|
||||
- gives users a clean upgrade path: "connect private bot for local files".
|
||||
|
||||
Risk:
|
||||
|
||||
- less magical than users expect from Telegram;
|
||||
- leads may send screenshots and expect them to work;
|
||||
- product copy must be clear.
|
||||
|
||||
### Option 2 - Official ephemeral media streaming to active desktop
|
||||
|
||||
🎯 7 🛡️ 8 🧠 8
|
||||
|
||||
Approx changed LOC: 2500-6000.
|
||||
|
||||
What it means:
|
||||
|
||||
- backend downloads media only while desktop is connected;
|
||||
- backend streams bytes to desktop and does not store them;
|
||||
- desktop commits attachment bytes before message row;
|
||||
- ACK waits for local acceptance or clean unsupported/offline decision.
|
||||
|
||||
Why it is viable:
|
||||
|
||||
- preserves convenience of official shared bot;
|
||||
- no durable backend media store;
|
||||
- can support common screenshots/documents.
|
||||
|
||||
Risk:
|
||||
|
||||
- backend still transiently sees file bytes;
|
||||
- many failure states;
|
||||
- requires new streaming attachment port;
|
||||
- current base64 attachment path is not the right transport;
|
||||
- harder to test than text.
|
||||
|
||||
### Option 3 - Backend encrypted media queue
|
||||
|
||||
🎯 6 🛡️ 8 🧠 9
|
||||
|
||||
Approx changed LOC: 3500-9000.
|
||||
|
||||
What it means:
|
||||
|
||||
- backend stores encrypted media or encrypted Telegram file capabilities for later desktop replay;
|
||||
- desktop decrypts and commits when it comes online;
|
||||
- official bot can feel reliable even while desktop is offline.
|
||||
|
||||
Why it is not first:
|
||||
|
||||
- this changes the product from "offline means offline" to "we queue sensitive content";
|
||||
- encrypted media queue is still a data retention system;
|
||||
- key management, replay, retention, deletion, and support diagnostics become much harder;
|
||||
- it competes with a simpler premium/advanced reliability mode later.
|
||||
|
||||
Use only after:
|
||||
|
||||
- text routing is stable;
|
||||
- ephemeral streaming is proven;
|
||||
- user demand for offline media is strong enough.
|
||||
|
||||
## 13. Decision Update
|
||||
|
||||
Recommended sequence:
|
||||
|
||||
```text
|
||||
1. Official shared bot:
|
||||
text + captions + topics + reply routing + no durable backend plaintext queue.
|
||||
|
||||
2. Own-bot adapter:
|
||||
local token + local polling + text first, then local media download.
|
||||
|
||||
3. Official shared bot media:
|
||||
ephemeral active-desktop streaming only, no backend disk.
|
||||
|
||||
4. Advanced reliability:
|
||||
encrypted backend queue for text/media only if explicitly enabled.
|
||||
```
|
||||
|
||||
This keeps the architecture provider-neutral and honest:
|
||||
|
||||
```text
|
||||
core messenger domain:
|
||||
route identity
|
||||
thread/topic mapping
|
||||
local message ledger
|
||||
attachment state machine
|
||||
delivery ledger
|
||||
|
||||
provider adapters:
|
||||
Telegram official adapter
|
||||
Telegram own-bot adapter
|
||||
future WhatsApp adapter
|
||||
future Discord adapter
|
||||
|
||||
storage ports:
|
||||
route registry
|
||||
inbound receipt store
|
||||
local turn ledger
|
||||
attachment content store
|
||||
outbound delivery ledger
|
||||
```
|
||||
|
||||
The main design rule:
|
||||
|
||||
```text
|
||||
No message row may claim an attachment is available unless the desktop has committed bytes locally.
|
||||
```
|
||||
|
||||
## 14. Places Still Worth Deeper Research
|
||||
|
||||
Next low-confidence areas:
|
||||
|
||||
- exact Telegram private-chat topics UX across clients when many teams exist;
|
||||
- whether `message_thread_id` behavior is consistent for private bot topics on desktop/mobile Telegram clients;
|
||||
- how to represent teammate messages inside one team topic without confusing the user;
|
||||
- whether captions/media groups should become one Agent Teams turn or multiple turns;
|
||||
- how to prevent model/tool prompt injection through Telegram captions and filenames;
|
||||
- which own-bot intake mode is best for desktop: long polling, local Bot API server, or optional tunnel.
|
||||
|
||||
736
docs/research/messenger-connectors-uncertainty-pass-31.md
Normal file
736
docs/research/messenger-connectors-uncertainty-pass-31.md
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
# Messenger Connectors - Uncertainty Pass 31
|
||||
|
||||
Date: 2026-04-29
|
||||
Scope: Telegram private-chat topics, one-topic-per-team topology, reply-to teammate routing, topic registry recoverability, and local inbox alignment
|
||||
|
||||
## Executive Delta
|
||||
|
||||
The next lowest-confidence area is not whether Telegram supports topics. It does.
|
||||
|
||||
The weak point is whether topics can be used as a stable product navigation layer without losing routing correctness:
|
||||
|
||||
```text
|
||||
Telegram private topic id
|
||||
-> app team route
|
||||
-> lead or teammate recipient
|
||||
-> durable local message
|
||||
-> agent reply
|
||||
-> Telegram message in the same topic
|
||||
-> user replies to a concrete teammate message
|
||||
```
|
||||
|
||||
The correct approach is:
|
||||
|
||||
```text
|
||||
One Telegram topic per team.
|
||||
Route the topic to the team.
|
||||
Route the recipient inside the team by reply-to message ledger, explicit command, or UI buttons.
|
||||
Default no-reply messages to the lead.
|
||||
Never create one bot or one topic per teammate as the default.
|
||||
```
|
||||
|
||||
⚠️ Main new finding: Telegram Bot API exposes create/edit/delete topic operations, but I do not see a Bot API method for listing all private-chat topics and recovering their ids. That means our app must treat topic ids as durable provider state and store them locally/backend-side from creation time. If the registry is lost, topic recovery is weak and may require creating replacement topics.
|
||||
|
||||
## Source Facts Rechecked
|
||||
|
||||
Telegram official facts checked on 2026-04-29:
|
||||
|
||||
- Bot API 9.3, dated December 31, 2025, added private-chat topic mode support.
|
||||
- Bot API 9.3 added `User.has_topics_enabled`, `Message.message_thread_id`, and `Message.is_topic_message` support for private chats with topic mode enabled.
|
||||
- Bot API 9.3 added `message_thread_id` support in private chats for `sendMessage`, media methods, `sendMediaGroup`, `copyMessage`, `forwardMessage`, `sendChatAction`, and topic-management methods.
|
||||
- Bot API 9.4, dated February 9, 2026, allowed bots to create topics in private chats with `createForumTopic`.
|
||||
- Bot API 9.4 added a BotFather setting that can prevent users from creating and deleting topics in private chats.
|
||||
- `User.has_topics_enabled` is returned by `getMe` and means the bot has forum topic mode enabled in private chats.
|
||||
- `User.allows_users_to_create_topics` is returned by `getMe` and indicates whether users may create/delete topics in private chats.
|
||||
- `createForumTopic` can create a topic in a forum supergroup chat or a private chat with a user.
|
||||
- `editForumTopic`, `deleteForumTopic`, and `unpinAllForumTopicMessages` support private chats with a user.
|
||||
- `sendMessage.message_thread_id` routes a message to a forum/private-chat topic.
|
||||
- Incoming `Message` includes optional `message_thread_id`, `is_topic_message`, `reply_to_message`, `media_group_id`, and text/media fields.
|
||||
- `ReplyParameters` lets a bot reply to a specific message id in the current chat or a specified chat.
|
||||
- `direct_messages_topic_id` is for channel direct messages chats and should not be confused with forum/private-chat `message_thread_id`.
|
||||
- Telegram forum topics are conceptually message threads. Nested message threads inside topics are not supported.
|
||||
- Telegram clients can have a "View as messages" setting for forums that shows messages from all topics in one stream. Treat this as a warning that visible topic grouping is a UX layer, not a routing authority.
|
||||
|
||||
Sources:
|
||||
|
||||
- https://core.telegram.org/bots/api-changelog
|
||||
- https://core.telegram.org/bots/api#getme
|
||||
- https://core.telegram.org/bots/api#user
|
||||
- https://core.telegram.org/bots/api#message
|
||||
- https://core.telegram.org/bots/api#sendmessage
|
||||
- https://core.telegram.org/bots/api#replyparameters
|
||||
- https://core.telegram.org/bots/api#createforumtopic
|
||||
- https://core.telegram.org/bots/api#editforumtopic
|
||||
- https://core.telegram.org/bots/api#deleteforumtopic
|
||||
- https://core.telegram.org/api/forum
|
||||
|
||||
Local code facts:
|
||||
|
||||
- `InboxMessage` already has `from`, `to`, `messageId`, `relayOfMessageId`, `conversationId`, and `replyToConversationId`.
|
||||
- `TeamDataService.sendMessage` passes `conversationId` and `replyToConversationId` into the message controller.
|
||||
- `CrossTeamService` already uses `conversationId` and `replyToConversationId` for cross-team threads.
|
||||
- OpenCode runtime delivery writes direct replies to either `user_sent_messages` or `member_inbox`.
|
||||
- `MessagesFilterPopover` already derives participants from message `from` and `to`.
|
||||
- `MessagesPanel` pending reply logic already treats `from=user -> to=member` and `from=member -> to=user` as meaningful route signals.
|
||||
- Current message model is string-name based, not stable-id based. Prior passes already identified stable route identity as a required feature layer.
|
||||
|
||||
Implication:
|
||||
|
||||
```text
|
||||
The app can represent the desired conversation shape,
|
||||
but messenger connectors need a provider-neutral route registry
|
||||
and provider message link ledger before Telegram topics are safe.
|
||||
```
|
||||
|
||||
## 1. Topic Is Team Scope, Not Recipient Scope
|
||||
|
||||
One topic should map to one team:
|
||||
|
||||
```text
|
||||
chatId + messageThreadId -> teamRouteId
|
||||
```
|
||||
|
||||
Recipient should be resolved inside that team:
|
||||
|
||||
```text
|
||||
incoming message in team topic
|
||||
-> if it replies to a known bot message from teammate X, route to teammate X
|
||||
-> else if it contains explicit recipient command/control, route to that recipient
|
||||
-> else route to lead
|
||||
```
|
||||
|
||||
Do not use topic title to route.
|
||||
|
||||
Topic title is display state:
|
||||
|
||||
```text
|
||||
"Frontend - Acme"
|
||||
"API - Acme"
|
||||
"API - Acme (archived)"
|
||||
```
|
||||
|
||||
Route identity must be persisted as:
|
||||
|
||||
```ts
|
||||
type MessengerTeamTopicRoute = {
|
||||
routeId: string;
|
||||
provider: 'telegram';
|
||||
botScope: 'official' | 'own_bot';
|
||||
botId: string;
|
||||
telegramChatIdHash: string;
|
||||
telegramChatIdEncrypted?: string;
|
||||
telegramMessageThreadId: number;
|
||||
teamId: string;
|
||||
teamGeneration: number;
|
||||
projectId: string | null;
|
||||
projectGeneration: number | null;
|
||||
displayTitle: string;
|
||||
status:
|
||||
| 'active'
|
||||
| 'create_pending'
|
||||
| 'create_ambiguous'
|
||||
| 'renaming'
|
||||
| 'renamed'
|
||||
| 'delete_seen'
|
||||
| 'replaced'
|
||||
| 'disabled'
|
||||
| 'error';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
```
|
||||
|
||||
For official shared bot, backend needs this route registry. For own-bot local mode, desktop can own it locally.
|
||||
|
||||
## 2. The Recoverability Problem
|
||||
|
||||
Creation is straightforward:
|
||||
|
||||
```text
|
||||
user starts bot
|
||||
desktop/backend knows Telegram chat id
|
||||
app creates topic for a team
|
||||
Telegram returns ForumTopic
|
||||
app stores message_thread_id
|
||||
```
|
||||
|
||||
The low-confidence part is recovery:
|
||||
|
||||
```text
|
||||
What if our route registry is lost?
|
||||
What if topic creation succeeded but the process crashed before storing topic id?
|
||||
What if user deletes or renames a topic?
|
||||
What if app creates a duplicate topic after a timeout?
|
||||
```
|
||||
|
||||
I do not see a Bot API method equivalent to "list my private-chat topics". Telegram's MTProto API has forum topic listing for forums, but Bot API docs expose topic creation/edit/delete operations and no simple list method. We should not build a core invariant on being able to reconstruct topic state from Telegram later.
|
||||
|
||||
Therefore:
|
||||
|
||||
```text
|
||||
Topic registry is authoritative local/backend product state.
|
||||
Telegram is an external projection.
|
||||
```
|
||||
|
||||
Creation must use a two-phase state:
|
||||
|
||||
```text
|
||||
create_pending -> active
|
||||
create_pending -> create_ambiguous
|
||||
create_ambiguous -> replaced
|
||||
```
|
||||
|
||||
If creation response is lost:
|
||||
|
||||
- do not keep retrying blindly;
|
||||
- show diagnostics in app;
|
||||
- allow "Create replacement topic";
|
||||
- optionally send a message in the general/default bot chat asking the user to pick the right topic if we can design a safe verification flow later.
|
||||
|
||||
## 3. User Topic Deletion And BotFather Settings
|
||||
|
||||
Bot API 9.4 added a setting to prevent users from creating and deleting topics in private chats.
|
||||
|
||||
Recommended official bot configuration:
|
||||
|
||||
```text
|
||||
Private chat topics enabled.
|
||||
Users cannot create/delete topics.
|
||||
Bot manages team topics.
|
||||
```
|
||||
|
||||
Why:
|
||||
|
||||
- fewer orphan routes;
|
||||
- fewer topic id invalidation bugs;
|
||||
- fewer accidental duplicates;
|
||||
- cleaner support story.
|
||||
|
||||
But the app must still handle deletion or invalid topic errors:
|
||||
|
||||
```text
|
||||
sendMessage(chatId, message_thread_id) fails
|
||||
-> mark topic route as error or delete_seen
|
||||
-> do not fallback silently to general chat
|
||||
-> create replacement topic only behind an explicit repair flow
|
||||
```
|
||||
|
||||
Silent fallback to general chat is dangerous because the user may read a message outside the intended team context.
|
||||
|
||||
## 4. Reply-To Teammate Routing
|
||||
|
||||
The desired product behavior:
|
||||
|
||||
```text
|
||||
User opens team topic.
|
||||
Bot posts messages from lead and teammates.
|
||||
User replies to a concrete message.
|
||||
App routes the reply to that concrete teammate.
|
||||
```
|
||||
|
||||
This is viable if we store provider message links:
|
||||
|
||||
```ts
|
||||
type ProviderMessageLink = {
|
||||
provider: 'telegram';
|
||||
routeId: string;
|
||||
providerChatIdHash: string;
|
||||
providerMessageThreadId: number;
|
||||
providerMessageId: number;
|
||||
internalMessageId: string;
|
||||
internalTeamId: string;
|
||||
internalFromMemberId: string;
|
||||
internalToMemberId: string | null;
|
||||
direction: 'telegram_to_app' | 'app_to_telegram';
|
||||
createdAt: string;
|
||||
};
|
||||
```
|
||||
|
||||
Incoming reply resolution:
|
||||
|
||||
```text
|
||||
if update.message.reply_to_message.message_id exists:
|
||||
lookup ProviderMessageLink by chatId + messageThreadId + reply_to_message.message_id
|
||||
if found and linked internal message came from teammate:
|
||||
route to that teammate
|
||||
if found and linked internal message came from lead:
|
||||
route to lead
|
||||
if found and linked internal message came from user:
|
||||
route to lead or use explicit reply target from that internal row
|
||||
else:
|
||||
use explicit recipient control or default to lead
|
||||
```
|
||||
|
||||
Important edge case:
|
||||
|
||||
```text
|
||||
Telegram topics cannot have nested message threads.
|
||||
Reply-to is only a pointer to a message, not a durable sub-thread per teammate.
|
||||
```
|
||||
|
||||
Therefore, reply-to should be a routing hint, not the entire conversation model.
|
||||
|
||||
## 5. Explicit Recipient Controls
|
||||
|
||||
Reply-to is natural but insufficient.
|
||||
|
||||
Users will send plain messages into a topic without replying. For those messages, the app needs a deterministic default and optional controls:
|
||||
|
||||
```text
|
||||
Default:
|
||||
message without reply -> team lead
|
||||
|
||||
Explicit route:
|
||||
/to teammate-name message
|
||||
or inline button "Reply to Alice"
|
||||
or short command menu
|
||||
```
|
||||
|
||||
Do not rely on Telegram mentions for routing:
|
||||
|
||||
- teammate names may not be Telegram users;
|
||||
- agents are not Telegram accounts;
|
||||
- inline mention semantics depend on Telegram user privacy and previous contact conditions;
|
||||
- local app member names can change.
|
||||
|
||||
Suggested official MVP:
|
||||
|
||||
```text
|
||||
No global "active recipient" state at first.
|
||||
Use reply-to for specific teammate replies.
|
||||
Use /to for explicit direct messages.
|
||||
Default to lead.
|
||||
```
|
||||
|
||||
This is less magical but safer than hidden mutable state.
|
||||
|
||||
## 6. Message Text Format In Telegram
|
||||
|
||||
Because client topic grouping can be changed by the user and messages can appear in flattened views, every bot message should carry lightweight context.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
[Frontend] Alice
|
||||
I pushed the fix and need review on the auth callback.
|
||||
```
|
||||
|
||||
For lead:
|
||||
|
||||
```text
|
||||
[Frontend] Lead
|
||||
I will ask Alice to check the failing test.
|
||||
```
|
||||
|
||||
For user-sent routed message acknowledgements:
|
||||
|
||||
```text
|
||||
[Frontend] to Alice
|
||||
Forwarded.
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- include team label in the first line;
|
||||
- include member display name for agent replies;
|
||||
- keep prefixes short;
|
||||
- do not include internal ids;
|
||||
- do not rely only on topic title;
|
||||
- avoid markdown complexity unless using explicit Telegram entities.
|
||||
|
||||
This makes flattened Telegram views survivable.
|
||||
|
||||
## 7. Topic Lifecycle State Machine
|
||||
|
||||
Suggested route lifecycle:
|
||||
|
||||
```text
|
||||
not_created
|
||||
-> create_pending
|
||||
-> active
|
||||
-> renaming
|
||||
-> active
|
||||
-> disabled
|
||||
|
||||
create_pending
|
||||
-> create_ambiguous
|
||||
-> replaced
|
||||
|
||||
active
|
||||
-> send_failed_topic_missing
|
||||
-> repair_required
|
||||
-> replacement_pending
|
||||
-> active
|
||||
|
||||
active
|
||||
-> archived
|
||||
-> disabled
|
||||
```
|
||||
|
||||
Do not delete topics automatically when a team is archived.
|
||||
|
||||
Recommended archive behavior:
|
||||
|
||||
- rename topic to include a compact archived marker;
|
||||
- send one final "team archived" message;
|
||||
- stop routing new user messages or route them to lead with a clear archived response;
|
||||
- keep local route state for historical provider links.
|
||||
|
||||
Deletion destroys user-visible history in Telegram and makes provider message links harder to explain.
|
||||
|
||||
## 8. Rename And Duplicate Teams
|
||||
|
||||
Current app still relies heavily on `teamName`, while prior research recommended stable team ids and route generations.
|
||||
|
||||
Telegram topic routing should not follow only team name.
|
||||
|
||||
If team is renamed:
|
||||
|
||||
```text
|
||||
teamId stays stable
|
||||
topic route stays stable
|
||||
displayTitle is updated
|
||||
editForumTopic is best-effort
|
||||
message prefix changes after local commit
|
||||
```
|
||||
|
||||
If two projects have same team name:
|
||||
|
||||
```text
|
||||
topic title must include a compact project discriminator
|
||||
routeId must include project/team stable ids
|
||||
```
|
||||
|
||||
Example topic title:
|
||||
|
||||
```text
|
||||
Frontend - acme-web
|
||||
Frontend - mobile-app
|
||||
```
|
||||
|
||||
Title length is capped, so the full identity must be in the registry, not in Telegram title.
|
||||
|
||||
## 9. Topic Creation Timing
|
||||
|
||||
Three possible creation timings:
|
||||
|
||||
### Lazy create on first outbound/inbound use
|
||||
|
||||
Pros:
|
||||
|
||||
- fewer unused topics;
|
||||
- less setup friction.
|
||||
|
||||
Cons:
|
||||
|
||||
- first message may be slower;
|
||||
- creation failure blocks communication at the worst moment;
|
||||
- ambiguous creation state can happen during a real user message.
|
||||
|
||||
### Eager create during connect wizard
|
||||
|
||||
Pros:
|
||||
|
||||
- setup verifies topic capability early;
|
||||
- failures are visible before real traffic;
|
||||
- topic registry is ready.
|
||||
|
||||
Cons:
|
||||
|
||||
- creates topics for teams user may never use;
|
||||
- can clutter Telegram.
|
||||
|
||||
### Hybrid
|
||||
|
||||
Recommended:
|
||||
|
||||
```text
|
||||
Create a topic for selected/active teams during connect wizard.
|
||||
Lazy-create for other teams when user enables them.
|
||||
```
|
||||
|
||||
This matches "minimum user actions" without creating too many topics.
|
||||
|
||||
## 10. Route Ambiguity Cases
|
||||
|
||||
Inbound ambiguity cases:
|
||||
|
||||
- message has no `message_thread_id`;
|
||||
- message has a thread id not in registry;
|
||||
- message has a known thread id but route is disabled;
|
||||
- message replies to a provider message id not in ledger;
|
||||
- reply target maps to a deleted/renamed teammate;
|
||||
- reply target maps to an old team generation;
|
||||
- user uses `/to` for an unknown teammate;
|
||||
- topic title was manually changed;
|
||||
- duplicate topic exists for the same team;
|
||||
- user forwards/copies messages between topics;
|
||||
- media group spans a topic but parts arrive separately;
|
||||
- update contains `direct_messages_topic` from channel direct messages, not private chat topic;
|
||||
- bot receives a message outside private chat if added to a group.
|
||||
|
||||
Resolution policy:
|
||||
|
||||
```text
|
||||
Unknown topic -> do not deliver to agent, send repair/unknown-topic notice.
|
||||
Known topic + unknown reply target -> route to lead with quoted context.
|
||||
Known topic + stale teammate -> route to lead and mention stale target in internal metadata.
|
||||
No topic id -> onboarding/default command handling only.
|
||||
```
|
||||
|
||||
Never guess a team by topic title.
|
||||
|
||||
## 11. Local UI Implications
|
||||
|
||||
The current Messages panel can already show participant flows from `from` and `to`. For messenger connectors, add a feature-local projection rather than rewriting the existing panel first:
|
||||
|
||||
```ts
|
||||
type MessengerConversationProjection = {
|
||||
routeId: string;
|
||||
teamId: string;
|
||||
provider: 'telegram';
|
||||
providerTopicTitle: string;
|
||||
messages: Array<{
|
||||
internalMessageId: string;
|
||||
providerMessageId?: number;
|
||||
fromMemberId: string;
|
||||
toMemberId?: string;
|
||||
replyToInternalMessageId?: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
deliveryState: 'pending' | 'sent' | 'failed' | 'ambiguous';
|
||||
}>;
|
||||
};
|
||||
```
|
||||
|
||||
The renderer can keep using participant filters, but messenger-specific state should live in `src/features/messenger-connectors/renderer`:
|
||||
|
||||
```text
|
||||
messenger feature hook
|
||||
-> maps route/thread state into view model
|
||||
-> existing MessagesPanel can show the durable local messages
|
||||
-> optional connector status panel shows Telegram topic health
|
||||
```
|
||||
|
||||
Do not put Telegram concepts directly into shared `InboxMessage` unless they are provider-neutral.
|
||||
|
||||
Provider-specific fields belong in a feature table/store:
|
||||
|
||||
```text
|
||||
provider_message_links
|
||||
provider_route_registry
|
||||
provider_delivery_ledger
|
||||
```
|
||||
|
||||
## 12. Architecture Fit
|
||||
|
||||
This feature clearly qualifies for the canonical feature architecture:
|
||||
|
||||
```text
|
||||
src/features/messenger-connectors/
|
||||
contracts/
|
||||
core/
|
||||
domain/
|
||||
route.ts
|
||||
topic.ts
|
||||
recipient-resolution.ts
|
||||
provider-message-link.ts
|
||||
application/
|
||||
ports.ts
|
||||
connect-messenger.ts
|
||||
receive-provider-update.ts
|
||||
send-provider-reply.ts
|
||||
repair-topic-route.ts
|
||||
main/
|
||||
composition/
|
||||
adapters/
|
||||
input/
|
||||
ipc/
|
||||
telegram-webhook/
|
||||
desktop-relay/
|
||||
output/
|
||||
telegram/
|
||||
team-messages/
|
||||
local-store/
|
||||
infrastructure/
|
||||
preload/
|
||||
renderer/
|
||||
```
|
||||
|
||||
Core domain invariants:
|
||||
|
||||
```text
|
||||
1. Provider topic title never determines route identity.
|
||||
2. Provider thread id maps to exactly one active team route per bot/chat.
|
||||
3. Recipient resolution is deterministic and auditable.
|
||||
4. Unknown topic never reaches an agent as a normal user message.
|
||||
5. Every outbound Telegram message that can be replied to has a ProviderMessageLink.
|
||||
6. Topic repair never silently changes the user's message destination.
|
||||
```
|
||||
|
||||
## 13. Top 3 Options
|
||||
|
||||
### Option 1 - One topic per team, reply-to ledger, default to lead, `/to` escape hatch
|
||||
|
||||
🎯 8 🛡️ 9 🧠 6
|
||||
|
||||
Approx changed LOC: 2500-5500.
|
||||
|
||||
What it means:
|
||||
|
||||
- each team has one Telegram private topic;
|
||||
- inbound messages in that topic route to the lead by default;
|
||||
- replying to a known teammate message routes to that teammate;
|
||||
- `/to teammate message` provides explicit routing;
|
||||
- topic id and provider message links are stored durably;
|
||||
- unknown/stale topics enter repair flow.
|
||||
|
||||
Why this is best:
|
||||
|
||||
- matches the user's selected model;
|
||||
- avoids topic explosion;
|
||||
- works with current `from`/`to` message model;
|
||||
- scales to many teams better than per-teammate topics;
|
||||
- keeps routing deterministic.
|
||||
|
||||
Risk:
|
||||
|
||||
- users must learn reply-to or `/to` for teammate-specific messages;
|
||||
- if provider message link ledger is missing, teammate routing falls back to lead;
|
||||
- requires solid route registry.
|
||||
|
||||
### Option 2 - One topic per team with mutable active recipient controls
|
||||
|
||||
🎯 6 🛡️ 7 🧠 7
|
||||
|
||||
Approx changed LOC: 3500-7000.
|
||||
|
||||
What it means:
|
||||
|
||||
- each topic has controls such as "Active recipient: Alice";
|
||||
- user taps inline buttons or commands to switch active recipient;
|
||||
- plain messages route to current active recipient until changed.
|
||||
|
||||
Why it is tempting:
|
||||
|
||||
- fewer reply-to requirements;
|
||||
- feels convenient on mobile;
|
||||
- user can have a visible selected target.
|
||||
|
||||
Risk:
|
||||
|
||||
- hidden mutable state across desktop and phone is easy to misunderstand;
|
||||
- two devices/users can change active recipient unexpectedly;
|
||||
- stale controls can route messages incorrectly;
|
||||
- callback handling and status messages add complexity.
|
||||
|
||||
This can be added later after Option 1, but I would not make it the first model.
|
||||
|
||||
### Option 3 - One topic per teammate or per internal conversation
|
||||
|
||||
🎯 4 🛡️ 6 🧠 8
|
||||
|
||||
Approx changed LOC: 4000-9000.
|
||||
|
||||
What it means:
|
||||
|
||||
- team lead has one topic;
|
||||
- each teammate has a separate topic;
|
||||
- or each conversation creates a topic.
|
||||
|
||||
Why it looks reliable:
|
||||
|
||||
- recipient is obvious from topic;
|
||||
- fewer reply-to resolution rules.
|
||||
|
||||
Why it is worse:
|
||||
|
||||
- topic count explodes;
|
||||
- Telegram UI becomes cluttered;
|
||||
- team context fragments;
|
||||
- archiving/renaming/recovering many topics is painful;
|
||||
- cross-team/project grouping becomes harder;
|
||||
- user wanted one team context, not dozens of technical threads.
|
||||
|
||||
Use only for a future "power mode" if users explicitly ask for per-agent topics.
|
||||
|
||||
## 14. Decision Update
|
||||
|
||||
Recommended design:
|
||||
|
||||
```text
|
||||
Default official bot:
|
||||
one private topic per team
|
||||
topic id maps to team route
|
||||
default route to lead
|
||||
reply-to route to teammate through ProviderMessageLink
|
||||
`/to` command as explicit escape hatch
|
||||
no mutable active recipient in MVP
|
||||
```
|
||||
|
||||
Required build blocks before implementation:
|
||||
|
||||
```text
|
||||
1. Stable TeamRoute identity independent of teamName.
|
||||
2. MessengerTopicRegistry with route generations and repair states.
|
||||
3. ProviderMessageLink ledger for every Telegram outbound message.
|
||||
4. RecipientResolver pure domain service.
|
||||
5. UnknownTopicPolicy that never sends unknown messages to agents.
|
||||
6. TopicRepair use case.
|
||||
7. Tests for duplicate, deleted, renamed, stale, and unknown topics.
|
||||
```
|
||||
|
||||
The most important invariant:
|
||||
|
||||
```text
|
||||
Telegram topic/thread id chooses team.
|
||||
Provider reply-to message id chooses teammate.
|
||||
Plain topic message chooses lead.
|
||||
```
|
||||
|
||||
## 15. Tests To Write First
|
||||
|
||||
Domain tests:
|
||||
|
||||
- known topic + no reply -> lead;
|
||||
- known topic + reply to lead message -> lead;
|
||||
- known topic + reply to teammate message -> teammate;
|
||||
- known topic + reply to user message -> lead;
|
||||
- known topic + unknown reply message id -> lead with ambiguity metadata;
|
||||
- unknown topic -> repair/notice, not agent delivery;
|
||||
- disabled topic -> archived/disabled response, not agent delivery;
|
||||
- duplicate topic route -> terminal config error;
|
||||
- renamed team -> same route id, updated display title;
|
||||
- deleted teammate -> lead fallback with stale target metadata;
|
||||
- `/to Alice hello` -> Alice;
|
||||
- `/to unknown hello` -> lead or error notice by policy;
|
||||
- media group in known topic -> same team route for all parts.
|
||||
|
||||
Adapter tests:
|
||||
|
||||
- `createForumTopic` success persists `message_thread_id`;
|
||||
- create response lost enters `create_ambiguous`;
|
||||
- `sendMessage` includes correct `message_thread_id`;
|
||||
- `sendMessage` failure for topic not found marks repair-required;
|
||||
- inbound update stores provider message link before local delivery ACK;
|
||||
- outbound provider message id is stored before considering Telegram delivery complete;
|
||||
- duplicate webhook with same provider message id returns existing local route.
|
||||
|
||||
Renderer tests:
|
||||
|
||||
- connector status panel shows topic healthy/error/repair-required;
|
||||
- message row prefix includes team/member context for Telegram projection;
|
||||
- participant filters still work with messenger-originated messages;
|
||||
- reply-to unavailable shows lead fallback reason.
|
||||
|
||||
## 16. Remaining Low-Confidence Areas
|
||||
|
||||
Still worth deeper research next:
|
||||
|
||||
- exact Telegram client UX for private-chat topics on mobile and desktop after Bot API 9.3/9.4;
|
||||
- whether BotFather private topic settings can be configured programmatically or only manually;
|
||||
- exact error codes returned when a private topic is deleted or disabled;
|
||||
- whether Telegram private topics expose enough update events to detect user rename/delete promptly;
|
||||
- how long topic titles can remain readable with many projects and similar team names;
|
||||
- whether `sendMessageDraft` could improve "agent is typing" UX per team topic without creating noisy messages;
|
||||
- how to migrate a user from official shared bot topics to own-bot topics without losing local route history.
|
||||
|
||||
796
docs/research/messenger-connectors-uncertainty-pass-32.md
Normal file
796
docs/research/messenger-connectors-uncertainty-pass-32.md
Normal file
|
|
@ -0,0 +1,796 @@
|
|||
# Messenger Connectors - Uncertainty Pass 32
|
||||
|
||||
Date: 2026-04-29
|
||||
Scope: agent reply capture, outbound Telegram delivery, message visibility policy, duplicate prevention, and provider delivery ambiguity
|
||||
|
||||
## Executive Delta
|
||||
|
||||
The next lowest-confidence boundary is the final leg:
|
||||
|
||||
```text
|
||||
agent/team message
|
||||
-> local app feed
|
||||
-> outbound eligibility decision
|
||||
-> Telegram sendMessage
|
||||
-> provider message id
|
||||
-> future reply-to route
|
||||
```
|
||||
|
||||
This is where two severe bugs can happen:
|
||||
|
||||
```text
|
||||
1. Privacy leak:
|
||||
internal thoughts, tool summaries, teammate protocol XML, retry prompts, or slash output
|
||||
get sent to Telegram as if they were user-facing replies.
|
||||
|
||||
2. Duplicate provider send:
|
||||
Telegram receives a sendMessage request, but our process times out before seeing the result.
|
||||
Automatic retry can send the same user-visible reply twice.
|
||||
```
|
||||
|
||||
The fix is a dedicated outbound projection layer:
|
||||
|
||||
```text
|
||||
MessengerOutboundProjectionGate
|
||||
decides if a local message is eligible for external provider delivery
|
||||
|
||||
MessengerProviderDeliveryLedger
|
||||
records provider send intent, in-flight state, success, ambiguity, and terminal failure
|
||||
|
||||
ProviderMessageLink
|
||||
records Telegram message id after success so reply-to routing works later
|
||||
```
|
||||
|
||||
Do not use the renderer feed or `sentMessages.json` as the outbound provider queue. They are useful inputs, but not the delivery protocol.
|
||||
|
||||
## Source Facts Rechecked
|
||||
|
||||
Telegram official facts checked on 2026-04-29:
|
||||
|
||||
- Bot API methods return a JSON object with `ok`; successful calls put the method result in `result`.
|
||||
- `sendMessage` sends text and returns the sent `Message` on success.
|
||||
- `sendMessage` supports `message_thread_id` for forum/private-chat topics.
|
||||
- `sendMessage` supports `reply_parameters` for replying to a specific message.
|
||||
- When using webhook inline responses to call Bot API methods, Telegram says it is not possible to know whether the method succeeded or to get its result.
|
||||
- `ResponseParameters.retry_after` tells how many seconds to wait after flood control.
|
||||
- Telegram FAQ recommends avoiding more than one message per second in a single chat; otherwise 429 errors can happen.
|
||||
- Telegram FAQ says bots should not rely on webhook inline response if they need to know the result of the method.
|
||||
- Bot API docs and FAQ do not expose a client-supplied idempotency key for `sendMessage`.
|
||||
|
||||
Sources:
|
||||
|
||||
- https://core.telegram.org/bots/api#making-requests
|
||||
- https://core.telegram.org/bots/api#making-requests-when-getting-updates
|
||||
- https://core.telegram.org/bots/api#sendmessage
|
||||
- https://core.telegram.org/bots/api#replyparameters
|
||||
- https://core.telegram.org/bots/api#responseparameters
|
||||
- https://core.telegram.org/bots/faq
|
||||
|
||||
Local code facts:
|
||||
|
||||
- `TeamSentMessagesStore` persists `sentMessages.json`, but it caps history at 200 messages and is optimized as a local UI/persistence store, not a provider delivery ledger.
|
||||
- `TeamSentMessagesStore` preserves message fields such as `from`, `to`, `source`, `leadSessionId`, `conversationId`, and `replyToConversationId`.
|
||||
- `TeamDataService.extractLeadSessionTextsFromJsonl` creates lead-session text rows with `source: 'lead_session'` and usually no `to`.
|
||||
- `leadSessionMessageExtractor` creates slash command result rows with `source: 'lead_session'` and `messageKind: 'slash_command_result'`.
|
||||
- `TeamProvisioningService` captures native `SendMessage` tool calls. `recipient === 'user'` is persisted to `sentMessages.json`; other recipients are persisted to inbox.
|
||||
- `relayLeadInboxMessages` captures plain lead output for inbox relay, strips agent-only blocks, then persists a `lead_process` message to user.
|
||||
- `stripAgentBlocks` removes `info_for_agent`, legacy agent blocks, and OpenCode runtime delivery blocks.
|
||||
- `inboxNoise` detects internal JSON noise and teammate-message XML protocol artifacts.
|
||||
- `RuntimeDeliveryService` already has strong local idempotency ideas: journal begin, payload hash conflict detection, destination verification, committed state, failed retryable state, and reconciler.
|
||||
- Existing runtime delivery works for local destinations because it can verify local files/stores. Telegram provider sends are different because success may be unknowable after network timeout.
|
||||
|
||||
Implication:
|
||||
|
||||
```text
|
||||
The current app has good ingredients,
|
||||
but messenger outbound needs a separate provider delivery ledger
|
||||
with stricter "external visibility" rules than the UI feed.
|
||||
```
|
||||
|
||||
## 1. Outbound Eligibility Is A Security Boundary
|
||||
|
||||
The local feed contains multiple categories:
|
||||
|
||||
```text
|
||||
user_sent
|
||||
lead_process
|
||||
lead_session
|
||||
runtime_delivery
|
||||
inbox
|
||||
system_notification
|
||||
cross_team
|
||||
cross_team_sent
|
||||
slash_command_result
|
||||
tool summaries
|
||||
command output
|
||||
internal protocol blocks
|
||||
noise JSON
|
||||
```
|
||||
|
||||
Only a small subset should be allowed to leave the app through Telegram.
|
||||
|
||||
Minimal provider-send eligibility:
|
||||
|
||||
```text
|
||||
message.to == "user"
|
||||
message.from is a known active team member or lead
|
||||
message.source is user-visible by policy
|
||||
message.text remains non-empty after sanitization
|
||||
message is linked to a provider route or an explicit publish action
|
||||
message has not already been sent to that provider route
|
||||
route is active
|
||||
topic is active
|
||||
outbound policy allows this member/source/kind
|
||||
```
|
||||
|
||||
Hard excludes:
|
||||
|
||||
```text
|
||||
message.from == "user"
|
||||
message.from == "system"
|
||||
message.to != "user"
|
||||
messageKind == "slash_command" unless explicitly mirrored as a user command echo
|
||||
messageKind == "slash_command_result" unless explicitly requested
|
||||
isInboxNoiseMessage(text)
|
||||
isThoughtProtocolNoise(text)
|
||||
stripAgentBlocks(text) is empty
|
||||
only teammate-message XML blocks
|
||||
tool-only rows with no human answer
|
||||
debug diagnostics
|
||||
runtime retry prompt text
|
||||
permission_request JSON
|
||||
```
|
||||
|
||||
The important rule:
|
||||
|
||||
```text
|
||||
If a message is visible in the local app, that does not automatically mean it is safe to send to Telegram.
|
||||
```
|
||||
|
||||
## 2. What Counts As A User-Facing Agent Reply
|
||||
|
||||
For the Telegram topic product, user-facing means:
|
||||
|
||||
```text
|
||||
Lead or teammate intentionally answered the external user.
|
||||
```
|
||||
|
||||
Good candidates:
|
||||
|
||||
- `SendMessage(to="user")` captured from lead or teammate runtime.
|
||||
- Runtime delivery envelope whose destination is `user_sent_messages`.
|
||||
- A visible reply proof with `relayOfMessageId` linked to a messenger inbound turn.
|
||||
- A manual user action in our UI like "send this to Telegram".
|
||||
|
||||
Risky candidates:
|
||||
|
||||
- Lead session thoughts without `to`.
|
||||
- Plain assistant text captured from stdout during a relay batch.
|
||||
- Slash command output.
|
||||
- Task/comment notifications.
|
||||
- Cross-team internal coordination.
|
||||
- Teammate-to-teammate messages.
|
||||
|
||||
Recommended MVP:
|
||||
|
||||
```text
|
||||
Auto-send to Telegram only messages that have an explicit destination to external user.
|
||||
Do not auto-send generic lead thoughts.
|
||||
```
|
||||
|
||||
This means:
|
||||
|
||||
```text
|
||||
lead_process with to=user -> eligible if linked to route
|
||||
runtime delivery to user -> eligible if linked to route
|
||||
lead_session without to -> not eligible
|
||||
slash_command_result -> not eligible by default
|
||||
cross_team_sent -> not eligible unless to=user and explicit external link exists
|
||||
```
|
||||
|
||||
## 3. User Wants Teammate Messages Too
|
||||
|
||||
The user's desired behavior:
|
||||
|
||||
```text
|
||||
Messages from other teammates to the user should appear in Telegram too,
|
||||
signed by each teammate.
|
||||
```
|
||||
|
||||
This is real and understandable. The safe model:
|
||||
|
||||
```text
|
||||
If any team member sends a message to "user" in a route-linked conversation,
|
||||
send it into the team topic with a member prefix.
|
||||
```
|
||||
|
||||
Example Telegram rendering:
|
||||
|
||||
```text
|
||||
[Frontend] Alice
|
||||
I found the failing test. The auth callback returns before token refresh completes.
|
||||
```
|
||||
|
||||
```text
|
||||
[Frontend] Lead
|
||||
Alice is checking the failing test. I will update you when she has a patch.
|
||||
```
|
||||
|
||||
Do not send teammate-internal chatter:
|
||||
|
||||
```text
|
||||
Alice -> Lead: "Can you clarify the expected API?"
|
||||
Lead -> Bob: "Please review Alice's patch"
|
||||
Bob -> Alice: "Approved"
|
||||
```
|
||||
|
||||
unless the destination is explicitly `user`.
|
||||
|
||||
Therefore the outbound projection should key off destination, not role:
|
||||
|
||||
```text
|
||||
to=user + route link + eligible source -> send to Telegram
|
||||
to=lead/teammate/cross-team -> do not send
|
||||
```
|
||||
|
||||
## 4. Route Link Requirement
|
||||
|
||||
Do not send every `to=user` message to Telegram. The user may have multiple channels:
|
||||
|
||||
```text
|
||||
local UI only
|
||||
Telegram official bot
|
||||
Telegram own bot
|
||||
future WhatsApp
|
||||
future Discord
|
||||
```
|
||||
|
||||
Outbound needs an explicit route link:
|
||||
|
||||
```ts
|
||||
type MessengerOutboundContext =
|
||||
| {
|
||||
kind: 'reply_to_provider_turn';
|
||||
routeId: string;
|
||||
inboundProviderMessageKey: string;
|
||||
internalInboundMessageId: string;
|
||||
}
|
||||
| {
|
||||
kind: 'manual_publish';
|
||||
routeId: string;
|
||||
requestedBy: 'user';
|
||||
localMessageId: string;
|
||||
};
|
||||
```
|
||||
|
||||
For auto-send MVP, require `reply_to_provider_turn`.
|
||||
|
||||
Manual publish can come later. Without route link, local app replies remain local app replies.
|
||||
|
||||
## 5. Provider Delivery Is Not Local Delivery
|
||||
|
||||
Existing `RuntimeDeliveryService` can retry local destinations because it can verify them:
|
||||
|
||||
```text
|
||||
write deterministic local message id
|
||||
verify file/store contains destination message id
|
||||
mark committed
|
||||
```
|
||||
|
||||
Telegram is different:
|
||||
|
||||
```text
|
||||
POST sendMessage
|
||||
network timeout before response
|
||||
unknown whether Telegram created the message
|
||||
no Bot API client idempotency key
|
||||
cannot verify by deterministic local id
|
||||
```
|
||||
|
||||
Therefore provider delivery states need an ambiguity state:
|
||||
|
||||
```ts
|
||||
type MessengerProviderDeliveryStatus =
|
||||
| 'pending'
|
||||
| 'send_in_flight'
|
||||
| 'sent'
|
||||
| 'send_ambiguous'
|
||||
| 'rate_limited'
|
||||
| 'failed_retryable_before_send'
|
||||
| 'failed_terminal'
|
||||
| 'cancelled';
|
||||
```
|
||||
|
||||
Critical rule:
|
||||
|
||||
```text
|
||||
Never automatically retry send_in_flight after a transport timeout
|
||||
unless the provider adapter can prove the previous attempt did not reach Telegram.
|
||||
```
|
||||
|
||||
Most HTTP timeout cases cannot prove that.
|
||||
|
||||
## 6. Provider Delivery Ledger
|
||||
|
||||
Suggested ledger:
|
||||
|
||||
```ts
|
||||
type MessengerProviderDeliveryRecord = {
|
||||
idempotencyKey: string;
|
||||
provider: 'telegram';
|
||||
botScope: 'official' | 'own_bot';
|
||||
routeId: string;
|
||||
providerChatIdHash: string;
|
||||
providerMessageThreadId: number | null;
|
||||
internalMessageId: string;
|
||||
internalPayloadHash: string;
|
||||
visibilityDecisionId: string;
|
||||
status: MessengerProviderDeliveryStatus;
|
||||
providerMessageId: number | null;
|
||||
replyToProviderMessageId: number | null;
|
||||
attempts: number;
|
||||
nextAttemptAt: string | null;
|
||||
ambiguousSince: string | null;
|
||||
lastErrorCode: string | null;
|
||||
lastErrorMessageRedacted: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
sentAt: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
Idempotency key should be deterministic:
|
||||
|
||||
```text
|
||||
sha256(provider + routeId + internalMessageId + normalizedTextHash + deliveryKind)
|
||||
```
|
||||
|
||||
Payload hash prevents accidental reuse:
|
||||
|
||||
```text
|
||||
same idempotencyKey + different payloadHash -> conflict, terminal
|
||||
```
|
||||
|
||||
When `sent`:
|
||||
|
||||
```text
|
||||
create ProviderMessageLink:
|
||||
providerMessageId -> internalMessageId
|
||||
```
|
||||
|
||||
When `send_ambiguous`:
|
||||
|
||||
```text
|
||||
do not create ProviderMessageLink
|
||||
show warning in connector status
|
||||
allow manual "send again anyway" or "mark as sent" if future support flow exists
|
||||
```
|
||||
|
||||
## 7. Send State Machine
|
||||
|
||||
Safe provider send state machine:
|
||||
|
||||
```text
|
||||
pending
|
||||
-> send_in_flight
|
||||
-> sent
|
||||
|
||||
pending
|
||||
-> failed_retryable_before_send
|
||||
-> pending
|
||||
|
||||
send_in_flight
|
||||
-> rate_limited
|
||||
-> pending at retry_after
|
||||
|
||||
send_in_flight
|
||||
-> send_ambiguous
|
||||
|
||||
send_in_flight
|
||||
-> failed_terminal
|
||||
```
|
||||
|
||||
Retryable before-send examples:
|
||||
|
||||
- route temporarily locked;
|
||||
- local rate limiter says wait;
|
||||
- backend/desktop connection unavailable before calling Telegram;
|
||||
- provider adapter rejected validation before network send.
|
||||
|
||||
Ambiguous examples:
|
||||
|
||||
- request body was handed to HTTP client and connection timed out;
|
||||
- process crashed after starting `sendMessage`;
|
||||
- backend sent inline webhook response with method payload and needs the provider result;
|
||||
- connection reset after partial response;
|
||||
- app received malformed response after Telegram may have accepted request.
|
||||
|
||||
Terminal examples:
|
||||
|
||||
- blocked by user;
|
||||
- chat not found;
|
||||
- topic missing and repair is required;
|
||||
- message text empty after sanitization;
|
||||
- payload too long and split policy disabled;
|
||||
- route disabled;
|
||||
- payload hash conflict.
|
||||
|
||||
## 8. Do Not Use Inline Webhook Response For Outbound Replies
|
||||
|
||||
Telegram allows calling a Bot API method by returning it in the webhook response. This is tempting for fast replies.
|
||||
|
||||
Do not use it for messenger outbound replies.
|
||||
|
||||
Reason:
|
||||
|
||||
```text
|
||||
Telegram says we cannot know if the inline method succeeded or get its result.
|
||||
Without the returned Message, we cannot store providerMessageId.
|
||||
Without providerMessageId, reply-to teammate routing becomes weaker.
|
||||
```
|
||||
|
||||
Use normal Bot API calls for outbound messages:
|
||||
|
||||
```text
|
||||
POST /bot<TOKEN>/sendMessage
|
||||
await result
|
||||
persist provider message id
|
||||
then mark sent
|
||||
```
|
||||
|
||||
Inline webhook response is acceptable only for non-critical throwaway notices where no future reply routing is needed.
|
||||
|
||||
## 9. Rate Limiting
|
||||
|
||||
Telegram FAQ warns to avoid more than one message per second in a single chat.
|
||||
|
||||
For one-topic-per-team inside one private chat, the chat-level limiter matters more than topic-level limiter:
|
||||
|
||||
```text
|
||||
same Telegram private chat
|
||||
many team topics
|
||||
many team replies
|
||||
one chat-level provider limit
|
||||
```
|
||||
|
||||
Add provider route limiter:
|
||||
|
||||
```text
|
||||
global bot limiter
|
||||
per chat limiter
|
||||
per route/topic limiter
|
||||
```
|
||||
|
||||
MVP values:
|
||||
|
||||
```text
|
||||
per chat: 1 message per second steady
|
||||
per route/topic: 1 message per second steady
|
||||
burst: small queue, for example 3 messages
|
||||
queue overflow: collapse or mark delayed
|
||||
```
|
||||
|
||||
Avoid splitting a single long answer into many Telegram messages unless necessary. If splitting is needed because text exceeds Telegram limit, send chunks under one ledger group and be careful:
|
||||
|
||||
```text
|
||||
part 1 sent
|
||||
part 2 ambiguous
|
||||
part 3 pending
|
||||
```
|
||||
|
||||
Multi-part provider delivery needs a group ledger, so MVP should keep replies concise and reject/trim with clear policy before adding splitting.
|
||||
|
||||
## 10. Text Sanitization And Formatting
|
||||
|
||||
Outbound text pipeline:
|
||||
|
||||
```text
|
||||
raw local message
|
||||
strip agent-only blocks
|
||||
strip teammate protocol blocks if present
|
||||
reject JSON noise
|
||||
normalize whitespace
|
||||
prefix with team/member context
|
||||
enforce max length
|
||||
send plain text or Telegram entities
|
||||
```
|
||||
|
||||
Avoid parse modes in MVP:
|
||||
|
||||
```text
|
||||
send plain text
|
||||
do not use MarkdownV2 until escaping is proven
|
||||
```
|
||||
|
||||
Reason:
|
||||
|
||||
- model output can contain arbitrary punctuation;
|
||||
- MarkdownV2 escaping is brittle;
|
||||
- malformed formatting can fail provider send;
|
||||
- provider failure after partial route logic increases ambiguity.
|
||||
|
||||
Use explicit Telegram entities later if rich formatting is necessary.
|
||||
|
||||
## 11. Reply-To Mapping
|
||||
|
||||
When sending a provider reply, use `reply_parameters` if we are replying to a known inbound provider message:
|
||||
|
||||
```text
|
||||
reply_to_provider_message_id = inbound Telegram message id
|
||||
message_thread_id = team topic id
|
||||
```
|
||||
|
||||
But do not depend only on Telegram reply UI.
|
||||
|
||||
Also store:
|
||||
|
||||
```text
|
||||
ProviderMessageLink(providerMessageId -> internalMessageId)
|
||||
```
|
||||
|
||||
Then future user replies can route:
|
||||
|
||||
```text
|
||||
reply_to_message.message_id
|
||||
-> ProviderMessageLink
|
||||
-> internal from member
|
||||
-> route to that teammate
|
||||
```
|
||||
|
||||
If provider send succeeds but link persistence fails:
|
||||
|
||||
```text
|
||||
send was externally visible
|
||||
do not retry send
|
||||
mark provider link missing
|
||||
schedule repair if possible
|
||||
```
|
||||
|
||||
This should become `sent_link_missing`, or `sent` with diagnostics. It is not a send failure.
|
||||
|
||||
## 12. Local Store Is Not Enough
|
||||
|
||||
`sentMessages.json` is capped at 200 rows. This is fine for a UI feed but not for provider reply-to history.
|
||||
|
||||
Provider message links need their own retention policy:
|
||||
|
||||
```text
|
||||
keep links for active route history window
|
||||
minimum 90 days or until route deletion by user
|
||||
prune only with route-level retention
|
||||
never prune solely because local sentMessages hit 200 rows
|
||||
```
|
||||
|
||||
If links are pruned:
|
||||
|
||||
- future replies to old Telegram messages route to lead;
|
||||
- UI should show "old reply target not available";
|
||||
- do not guess teammate from display prefix.
|
||||
|
||||
## 13. Deletion And Edits
|
||||
|
||||
MVP can ignore edits and deletions mostly, but not silently:
|
||||
|
||||
Inbound Telegram edited messages:
|
||||
|
||||
- do not mutate already delivered internal turns in MVP;
|
||||
- create an edit event or ignore with diagnostics;
|
||||
- if edited before desktop acceptance, process latest only if ingress design supports it.
|
||||
|
||||
Outbound local message edits:
|
||||
|
||||
- do not edit Telegram messages in MVP;
|
||||
- send corrections as new messages only on explicit action.
|
||||
|
||||
Telegram delete:
|
||||
|
||||
- if provider message deleted, later reply-to links may break;
|
||||
- keep link but mark stale when detected by send/reply errors.
|
||||
|
||||
This avoids complicated bidirectional sync in v1.
|
||||
|
||||
## 14. Failure Matrix
|
||||
|
||||
Critical cases:
|
||||
|
||||
- Local lead thought appears with no `to`.
|
||||
- Do not send.
|
||||
- Lead uses `SendMessage(to="user")` answering a Telegram-origin message.
|
||||
- Eligible, send to that route.
|
||||
- Teammate uses `SendMessage(to="user")` answering a Telegram-origin message.
|
||||
- Eligible, send to same team topic with teammate prefix.
|
||||
- Teammate sends to lead.
|
||||
- Not eligible.
|
||||
- Message contains only `<info_for_agent>`.
|
||||
- Strip to empty, not eligible.
|
||||
- Message contains teammate XML blocks.
|
||||
- Strip/block by protocol-noise policy.
|
||||
- Slash command output row appears.
|
||||
- Not eligible by default.
|
||||
- Provider route disabled after local reply was generated.
|
||||
- Mark terminal or cancelled, do not send.
|
||||
- Topic route repair-required.
|
||||
- Do not fallback to general chat.
|
||||
- Telegram returns 429 with retry_after.
|
||||
- Mark rate_limited, schedule retry after given time.
|
||||
- HTTP timeout after request sent.
|
||||
- Mark send_ambiguous, do not auto-retry.
|
||||
- HTTP timeout before request body leaves process.
|
||||
- If adapter can prove no send, mark failed_retryable_before_send.
|
||||
- Telegram returns success but local link write fails.
|
||||
- Do not retry provider send, repair link.
|
||||
- Duplicate local message event.
|
||||
- Ledger idempotency key returns existing provider status.
|
||||
- Same idempotency key with different text.
|
||||
- Payload conflict, terminal.
|
||||
- App restarts with `send_in_flight`.
|
||||
- Convert to send_ambiguous unless adapter has proof.
|
||||
- Provider message link pruned.
|
||||
- Future reply falls back to lead with stale target metadata.
|
||||
|
||||
## 15. Top 3 Options
|
||||
|
||||
### Option 1 - Strict outbound projection gate + provider delivery ledger
|
||||
|
||||
🎯 9 🛡️ 9 🧠 7
|
||||
|
||||
Approx changed LOC: 2500-5500.
|
||||
|
||||
What it means:
|
||||
|
||||
- build `MessengerOutboundProjectionGate`;
|
||||
- build `MessengerProviderDeliveryLedger`;
|
||||
- auto-send only explicit `to=user` replies linked to a provider route;
|
||||
- use Telegram normal API calls, not inline webhook response, for routable replies;
|
||||
- mark network unknowns as `send_ambiguous`, not retryable;
|
||||
- store `ProviderMessageLink` after success.
|
||||
|
||||
Why this is best:
|
||||
|
||||
- prevents internal-message leakage;
|
||||
- avoids unsafe Telegram duplicates;
|
||||
- supports teammate messages to user;
|
||||
- gives reply-to routing a durable provider message id;
|
||||
- matches the feature architecture standard.
|
||||
|
||||
Risk:
|
||||
|
||||
- more code;
|
||||
- some ambiguous sends need user-visible diagnostics;
|
||||
- initial behavior may feel conservative.
|
||||
|
||||
### Option 2 - Reuse `sentMessages.json` as outbound queue with simple dedupe
|
||||
|
||||
🎯 5 🛡️ 5 🧠 4
|
||||
|
||||
Approx changed LOC: 800-1800.
|
||||
|
||||
What it means:
|
||||
|
||||
- watch `sentMessages.json`;
|
||||
- send any new `to=user` message to Telegram;
|
||||
- store last sent internal message ids.
|
||||
|
||||
Why it is tempting:
|
||||
|
||||
- quick demo;
|
||||
- current system already writes user-directed lead messages there;
|
||||
- easy to observe from renderer.
|
||||
|
||||
Why it is risky:
|
||||
|
||||
- `sentMessages.json` is capped at 200;
|
||||
- it is not route-specific;
|
||||
- not all `to=user` messages should go to Telegram;
|
||||
- provider send ambiguity is not represented;
|
||||
- reply-to provider ids need another store anyway.
|
||||
|
||||
### Option 3 - Send all visible feed messages with broad filters
|
||||
|
||||
🎯 3 🛡️ 3 🧠 3
|
||||
|
||||
Approx changed LOC: 500-1400.
|
||||
|
||||
What it means:
|
||||
|
||||
- use `MessagesPanel`/feed projection;
|
||||
- filter obvious noise;
|
||||
- push visible items to Telegram.
|
||||
|
||||
Why it is bad:
|
||||
|
||||
- visibility in app is not external eligibility;
|
||||
- feed contains lead thoughts, slash results, diagnostics, and UI-specific projections;
|
||||
- dedupe is feed-oriented, not provider-send oriented;
|
||||
- provider reply-to routing remains fragile.
|
||||
|
||||
This should not be used beyond a throwaway prototype.
|
||||
|
||||
## 16. Decision Update
|
||||
|
||||
Recommended model:
|
||||
|
||||
```text
|
||||
Inbound Telegram turn creates route-linked internal message.
|
||||
Agent/team responses become local messages as today.
|
||||
MessengerOutboundProjectionGate observes durable local messages.
|
||||
Only explicit user-directed, route-linked replies become provider send intents.
|
||||
MessengerProviderDeliveryLedger handles Telegram send state.
|
||||
ProviderMessageLink stores successful Telegram message ids.
|
||||
Future reply-to routing uses ProviderMessageLink.
|
||||
```
|
||||
|
||||
Minimal eligibility formula:
|
||||
|
||||
```text
|
||||
eligible =
|
||||
route.active
|
||||
&& message.to == "user"
|
||||
&& message.from is active member
|
||||
&& origin/reply context links message to provider route
|
||||
&& message not already delivered to provider
|
||||
&& sanitized text non-empty
|
||||
&& message kind/source allowed by policy
|
||||
```
|
||||
|
||||
Important product behavior:
|
||||
|
||||
```text
|
||||
Teammate messages to user are sent to Telegram.
|
||||
Teammate messages to lead or other teammates are not sent.
|
||||
Lead thoughts without explicit to=user are not sent.
|
||||
```
|
||||
|
||||
## 17. Tests To Write First
|
||||
|
||||
Domain tests:
|
||||
|
||||
- `to=user` lead reply linked to provider route is eligible.
|
||||
- `to=user` teammate reply linked to provider route is eligible.
|
||||
- `to=user` local-only reply without route link is not eligible.
|
||||
- `to=lead` teammate message is not eligible.
|
||||
- lead session thought without `to` is not eligible.
|
||||
- slash command result is not eligible by default.
|
||||
- agent-only block strips to empty and is not eligible.
|
||||
- JSON noise is not eligible.
|
||||
- provider route disabled blocks eligibility.
|
||||
- same internal message maps to same provider idempotency key.
|
||||
- same idempotency key with changed payload is conflict.
|
||||
|
||||
Provider ledger tests:
|
||||
|
||||
- pending -> send_in_flight -> sent creates provider link.
|
||||
- pre-send validation failure is retryable.
|
||||
- 429 response stores retry_after and schedules retry.
|
||||
- HTTP timeout after request started becomes send_ambiguous.
|
||||
- restart with send_in_flight becomes send_ambiguous.
|
||||
- duplicate local event returns existing sent/ambiguous state.
|
||||
- success with provider link write failure does not retry provider send.
|
||||
|
||||
Adapter tests:
|
||||
|
||||
- Telegram send uses `message_thread_id`.
|
||||
- Telegram send uses `reply_parameters` when inbound provider message id is known.
|
||||
- Telegram send does not use webhook inline response for routable replies.
|
||||
- long text is rejected or handled by explicit split policy.
|
||||
- parse mode is omitted in MVP.
|
||||
|
||||
Renderer tests:
|
||||
|
||||
- connector status shows ambiguous provider sends.
|
||||
- ambiguous send has manual resolution affordance.
|
||||
- user can see why a local reply was not sent to Telegram.
|
||||
- teammate prefix renders in Telegram projection preview.
|
||||
|
||||
## 18. Remaining Low-Confidence Areas
|
||||
|
||||
Still worth deeper research next:
|
||||
|
||||
- exact local event source for teammate `SendMessage(to="user")` across all supported runtimes, not just OpenCode;
|
||||
- whether legacy Claude lead-session plain text should ever auto-send to Telegram or always require explicit SendMessage;
|
||||
- how to migrate old `sentMessages.json` rows into provider delivery state without accidental sends;
|
||||
- how to model manual "send again anyway" for `send_ambiguous` without hiding duplicate risk;
|
||||
- whether `sendMessageDraft` can safely show typing/progress in a topic without confusing delivery state;
|
||||
- exact Telegram error taxonomy for deleted private topic, blocked bot, and migrated chats in Bot API responses;
|
||||
- retention policy for `ProviderMessageLink` under privacy delete/export requirements.
|
||||
|
||||
901
docs/research/messenger-connectors-uncertainty-pass-33.md
Normal file
901
docs/research/messenger-connectors-uncertainty-pass-33.md
Normal file
|
|
@ -0,0 +1,901 @@
|
|||
# Messenger Connectors - Uncertainty Pass 33
|
||||
|
||||
Date: 2026-04-29
|
||||
Scope: Telegram account binding, connect wizard authorization, official shared bot vs own bot privacy, route ownership, revocation, and anti-hijack rules
|
||||
|
||||
## Executive Delta
|
||||
|
||||
The next lowest-confidence area is not the Telegram topic API.
|
||||
|
||||
It is the authorization boundary:
|
||||
|
||||
```text
|
||||
desktop install
|
||||
-> pending Telegram binding
|
||||
-> Telegram user/chat identity
|
||||
-> active team route
|
||||
-> provider topic creation
|
||||
-> future inbound/outbound permission
|
||||
```
|
||||
|
||||
If this is wrong, the feature can look correct but still have severe bugs:
|
||||
|
||||
```text
|
||||
1. A forwarded /start link binds the wrong Telegram account.
|
||||
2. A stale pairing code reactivates an old route.
|
||||
3. A username change breaks identity or routes to the wrong person.
|
||||
4. A copied desktop config gives another OS user access to a Telegram route.
|
||||
5. A backend log leaks chat ids, start payloads, or own-bot tokens.
|
||||
6. A route is activated before the desktop confirms the Telegram claim.
|
||||
```
|
||||
|
||||
The recommended shape is:
|
||||
|
||||
```text
|
||||
Desktop creates one-time pairing challenge
|
||||
-> user opens t.me/our_bot?start=<nonce>
|
||||
-> backend records Telegram claim
|
||||
-> desktop shows "Telegram account X wants to connect"
|
||||
-> user confirms in desktop
|
||||
-> route becomes active
|
||||
-> team topics are created or reconciled
|
||||
```
|
||||
|
||||
Do not treat Telegram `/start <payload>` alone as authorization. It proves that the message came from some Telegram account through Telegram, but it does not prove that the account is the same human currently controlling the desktop app.
|
||||
|
||||
## Source Facts Rechecked
|
||||
|
||||
Telegram official facts checked on 2026-04-29:
|
||||
|
||||
- Deep links let bots receive a `start` parameter in private chats. The parameter can use `A-Z`, `a-z`, `0-9`, `_`, `-`; Telegram recommends base64url, and the parameter can be up to 64 characters.
|
||||
- Bot links have the shape `https://t.me/<bot_username>?start=<parameter>`.
|
||||
- Bot API `Message` has `chat`, optional `from`, optional `message_thread_id`, and `is_topic_message` for forum supergroups or private chats with the bot.
|
||||
- Bot API `User.id` is the stable identifier. It may exceed 32 bits but has at most 52 significant bits. `username` is optional and must not be the primary identity.
|
||||
- Bot API `Chat.id` has the same 52-bit warning. Store it as string or signed 64-bit safe numeric representation, not as a JS lossy number in persistence boundaries.
|
||||
- Bot API `setWebhook.secret_token` causes Telegram to send `X-Telegram-Bot-Api-Secret-Token` on webhook requests. This verifies the webhook was set by us, not user identity.
|
||||
- Bot API 9.6, April 3, 2026, added Managed Bots. The created managed bot token can be fetched using `getManagedBotToken`. This means Managed Bots do not provide a "token hidden from manager bot/backend" privacy story if our bot/backend is the manager.
|
||||
- Telegram Mini Apps/Login-style data can be validated through HMAC with the bot token, and newer third-party validation can use Telegram Ed25519 signatures. This is useful for a web identity step, but it is more product/backend complexity than the default bot chat wizard needs.
|
||||
|
||||
Sources:
|
||||
|
||||
- https://core.telegram.org/bots/features#deep-linking
|
||||
- https://core.telegram.org/api/links#bot-links
|
||||
- https://core.telegram.org/bots/api#message
|
||||
- https://core.telegram.org/bots/api#user
|
||||
- https://core.telegram.org/bots/api#setwebhook
|
||||
- https://core.telegram.org/bots/api#recent-changes
|
||||
- https://core.telegram.org/bots/api#managedbotcreated
|
||||
- https://core.telegram.org/bots/api#keyboardbuttonrequestmanagedbot
|
||||
- https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
|
||||
|
||||
Local code facts checked:
|
||||
|
||||
- `docs/FEATURE_ARCHITECTURE_STANDARD.md` says medium/large cross-process features should live in a full feature slice with `contracts`, `core/domain`, `core/application`, `main`, `preload`, and `renderer`.
|
||||
- No obvious existing install-id or messenger-binding model was found in local searches.
|
||||
- `ConfigManager` persists app config at `~/.claude/agent-teams-config.json`.
|
||||
- `getAppDataPath()` returns app-owned data under Electron `userData` or a fallback app data directory, explicitly separate from `~/.claude`.
|
||||
- `ApiKeyService` already has a useful encrypted-secret pattern: Electron `safeStorage` first, AES-256-GCM local fallback, file mode `0o600`, and masked list output. This is relevant for optional own-bot token storage.
|
||||
- Current inbox architecture is based on `~/.claude/teams/{teamName}/inboxes/{memberName}.json`, with known race handling and message ids from earlier research.
|
||||
|
||||
Implication:
|
||||
|
||||
```text
|
||||
Messenger connectors need their own binding/security sub-slice.
|
||||
This should not be bolted onto Settings config as plain fields.
|
||||
```
|
||||
|
||||
## Top 3 Binding Options
|
||||
|
||||
### 1. Desktop-originated deep link plus desktop confirmation
|
||||
|
||||
🎯 9 🛡️ 9 🧠 6 Approx change size: 2500-5500 LOC
|
||||
|
||||
Flow:
|
||||
|
||||
```text
|
||||
1. Desktop generates an install identity and opens a connector setup session.
|
||||
2. Desktop asks official backend for a one-time pairing challenge.
|
||||
3. Backend stores only a challenge hash, selected capabilities, TTL, and desktop session id.
|
||||
4. Desktop shows QR/link: https://t.me/our_bot?start=<nonce>
|
||||
5. User opens link in Telegram.
|
||||
6. Official bot receives /start <nonce>.
|
||||
7. Backend validates nonce, marks challenge as telegram_claimed, records Telegram user/chat identity.
|
||||
8. Backend pushes "claim received" to desktop control channel.
|
||||
9. Desktop shows Telegram profile preview and asks for explicit confirm.
|
||||
10. Only after confirm, backend activates binding and the desktop creates/reconciles team routes/topics.
|
||||
```
|
||||
|
||||
Why this is best:
|
||||
|
||||
- The `/start` link is convenient.
|
||||
- A stolen link is not enough because the desktop still must confirm the exact Telegram account claim.
|
||||
- The route cannot become active while the user is away from desktop setup.
|
||||
- It fits official shared bot default.
|
||||
- It can reuse the same route model for own-bot later.
|
||||
|
||||
Main weaknesses:
|
||||
|
||||
- Requires a live desktop to complete binding.
|
||||
- Requires a backend control channel for official bot mode.
|
||||
- Backend will know Telegram chat id for official shared bot routing. This can be minimized and encrypted at rest, but not eliminated if backend sends messages through the shared bot.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Use as default MVP wizard.
|
||||
```
|
||||
|
||||
### 2. Bot-first short code entered into desktop
|
||||
|
||||
🎯 8 🛡️ 8 🧠 5 Approx change size: 1800-4000 LOC
|
||||
|
||||
Flow:
|
||||
|
||||
```text
|
||||
1. User opens our bot manually or from a generic link.
|
||||
2. Bot creates a short visible code for that Telegram chat.
|
||||
3. User enters or pastes the code into desktop.
|
||||
4. Desktop sends the code to backend through its authenticated setup session.
|
||||
5. Backend matches Telegram claim with desktop session.
|
||||
6. Desktop confirms and activates binding.
|
||||
```
|
||||
|
||||
Why it is useful:
|
||||
|
||||
- Works when deep links are blocked, copied incorrectly, or opened on the wrong device.
|
||||
- The Telegram chat is already known before desktop confirmation.
|
||||
- Good fallback for enterprise environments where QR/deep link is unreliable.
|
||||
|
||||
Main weaknesses:
|
||||
|
||||
- More user effort.
|
||||
- Short visible codes need strict TTL, rate limits, and replay protection.
|
||||
- If the user pastes code into the wrong desktop install, desktop confirmation still protects against silent activation, but UX can be confusing.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Keep as fallback, not the primary happy path.
|
||||
```
|
||||
|
||||
### 3. Telegram Mini App or Login Widget based verification
|
||||
|
||||
🎯 7 🛡️ 8 🧠 8 Approx change size: 3500-7500 LOC
|
||||
|
||||
Flow:
|
||||
|
||||
```text
|
||||
1. User opens a Telegram Mini App or Login Widget.
|
||||
2. Web identity data is validated using Telegram HMAC or Ed25519 validation.
|
||||
3. Backend links that verified Telegram identity to the user's app account or desktop setup session.
|
||||
4. Bot chat binding is completed after confirmation.
|
||||
```
|
||||
|
||||
Why it is attractive:
|
||||
|
||||
- Strong web identity story.
|
||||
- Better if Agent Teams later has real cloud accounts, team membership, device management, and web admin.
|
||||
- Can support "manage all connected Telegram devices" in a richer UI.
|
||||
|
||||
Main weaknesses:
|
||||
|
||||
- Too much product surface for MVP.
|
||||
- Needs domain setup, web identity screens, auth expiry rules, and account/device policy.
|
||||
- Still does not remove the need to bind a bot chat/topic route for messaging.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Good later for cloud account management.
|
||||
Do not use as default MVP unless Agent Teams already depends on cloud login.
|
||||
```
|
||||
|
||||
## Explicitly Rejected Option
|
||||
|
||||
### `/start` link alone activates the route
|
||||
|
||||
🎯 4 🛡️ 4 🧠 3 Approx change size: 900-2000 LOC
|
||||
|
||||
This is easy, but unsafe.
|
||||
|
||||
Failure case:
|
||||
|
||||
```text
|
||||
1. Desktop shows a setup QR.
|
||||
2. User screenshots or forwards it.
|
||||
3. Another Telegram account opens it first.
|
||||
4. Backend binds that chat to the user's teams.
|
||||
5. The wrong Telegram account receives team replies.
|
||||
```
|
||||
|
||||
This option can be patched with TTL and rate limits, but it still has the wrong trust boundary.
|
||||
|
||||
## Recommended Binding State Machine
|
||||
|
||||
```text
|
||||
unbound
|
||||
-> desktop_pending
|
||||
-> telegram_claimed
|
||||
-> desktop_confirmed
|
||||
-> active
|
||||
-> revoked
|
||||
```
|
||||
|
||||
Terminal or side states:
|
||||
|
||||
```text
|
||||
expired
|
||||
cancelled
|
||||
suspicious
|
||||
conflict
|
||||
provider_unavailable
|
||||
desktop_offline
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `desktop_pending`: challenge exists, but no Telegram user is associated yet.
|
||||
- `telegram_claimed`: Telegram user/chat has sent the nonce, but no route is active yet.
|
||||
- `desktop_confirmed`: user explicitly accepted the claim in desktop.
|
||||
- `active`: route may receive inbound Telegram messages and send outbound replies.
|
||||
- `expired`: TTL elapsed before confirmation. The `/start` payload must become useless.
|
||||
- `cancelled`: desktop cancelled setup. Later Telegram updates with that nonce get a generic expired response.
|
||||
- `suspicious`: multiple different Telegram users tried the same nonce, too many attempts, or mismatch with an already active binding.
|
||||
- `conflict`: same Telegram account/chat is already bound in a way that conflicts with the selected route policy.
|
||||
- `revoked`: route exists historically but is not allowed to deliver.
|
||||
|
||||
Important invariant:
|
||||
|
||||
```text
|
||||
No MessengerRoute can become active unless a Telegram claim and a desktop confirmation refer to the same pairing challenge id.
|
||||
```
|
||||
|
||||
## Pairing Challenge Shape
|
||||
|
||||
Provider-neutral domain model:
|
||||
|
||||
```ts
|
||||
interface MessengerPairingChallenge {
|
||||
id: string;
|
||||
provider: 'telegram';
|
||||
mode: 'official-shared-bot' | 'own-bot';
|
||||
installId: string;
|
||||
desktopSessionId: string;
|
||||
challengeHash: string;
|
||||
challengeCreatedAt: string;
|
||||
challengeExpiresAt: string;
|
||||
state:
|
||||
| 'desktop_pending'
|
||||
| 'telegram_claimed'
|
||||
| 'desktop_confirmed'
|
||||
| 'active'
|
||||
| 'expired'
|
||||
| 'cancelled'
|
||||
| 'suspicious'
|
||||
| 'conflict'
|
||||
| 'revoked';
|
||||
claimedBy?: {
|
||||
providerUserIdHash: string;
|
||||
providerChatIdHash: string;
|
||||
displayNameSnapshot: string;
|
||||
usernameSnapshot?: string;
|
||||
claimedAt: string;
|
||||
};
|
||||
capabilities: {
|
||||
canReceiveTeamTopics: boolean;
|
||||
canSendExternalUserMessages: boolean;
|
||||
canIssueCommands: boolean;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Nonce rules:
|
||||
|
||||
- Generate at least 128 bits of randomness.
|
||||
- Encode base64url without padding.
|
||||
- Stay under Telegram's 64-character `start` limit.
|
||||
- Store only a keyed hash server-side, not the raw nonce.
|
||||
- TTL should be 5-10 minutes.
|
||||
- Single use after `telegram_claimed`, with idempotent handling for duplicate update delivery.
|
||||
- Never log raw nonce.
|
||||
|
||||
## Identity Model
|
||||
|
||||
Provider-neutral route ownership:
|
||||
|
||||
```ts
|
||||
interface MessengerAccountBinding {
|
||||
id: string;
|
||||
provider: 'telegram';
|
||||
mode: 'official-shared-bot' | 'own-bot';
|
||||
installId: string;
|
||||
providerAccountRef: {
|
||||
userIdHash: string;
|
||||
chatIdHash: string;
|
||||
rawChatIdStorageRef?: string;
|
||||
};
|
||||
displaySnapshot: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
username?: string;
|
||||
languageCode?: string;
|
||||
};
|
||||
status: 'active' | 'revoked' | 'disabled' | 'provider_blocked_bot';
|
||||
createdAt: string;
|
||||
confirmedAt: string;
|
||||
lastSeenAt?: string;
|
||||
revokedAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Identity rules:
|
||||
|
||||
- Telegram `user.id` is identity.
|
||||
- Telegram `chat.id` is delivery destination.
|
||||
- Telegram `username` is display metadata only.
|
||||
- Store ids as strings at persistence/API boundaries to avoid JS precision mistakes.
|
||||
- Hash ids for logs and list views.
|
||||
- For official shared bot, backend needs a usable chat id at send time. Use KMS/envelope encryption at rest and redact logs. Do not pretend the backend has zero access.
|
||||
- For own-bot local mode, raw bot token and chat ids can stay local. This is the cleanest privacy story.
|
||||
|
||||
## Official Shared Bot Privacy Story
|
||||
|
||||
What is true:
|
||||
|
||||
```text
|
||||
Our backend receives Telegram webhook updates.
|
||||
Our backend sees enough Telegram identity to route the message.
|
||||
Our backend needs enough delivery identity to call sendMessage through our shared bot.
|
||||
```
|
||||
|
||||
What we can do:
|
||||
|
||||
```text
|
||||
1. No durable plaintext message queue while desktop is offline.
|
||||
2. Encrypt chat ids at rest.
|
||||
3. Hash ids in logs and analytics.
|
||||
4. Store minimal Telegram profile snapshots.
|
||||
5. Keep message bodies out of backend durable storage in default mode.
|
||||
6. If desktop is offline, send a clear offline notice instead of queueing plaintext.
|
||||
```
|
||||
|
||||
What we cannot honestly claim:
|
||||
|
||||
```text
|
||||
The official shared bot backend never sees Telegram metadata.
|
||||
```
|
||||
|
||||
Recommended copy:
|
||||
|
||||
```text
|
||||
Default bot is easiest: messages pass through Agent Teams relay while your desktop is online.
|
||||
We do not store message bodies in the default relay queue.
|
||||
For maximum privacy, connect your own bot locally.
|
||||
```
|
||||
|
||||
## Managed Bots Privacy Recheck
|
||||
|
||||
Managed Bots are useful, but not for "token invisible to us" if our bot/backend is the manager.
|
||||
|
||||
Official docs say:
|
||||
|
||||
```text
|
||||
ManagedBotCreated.bot token can be fetched using getManagedBotToken.
|
||||
ManagedBotUpdated.bot token can be fetched using getManagedBotToken.
|
||||
```
|
||||
|
||||
So the manager bot can fetch the created bot token.
|
||||
|
||||
This means:
|
||||
|
||||
```text
|
||||
If our backend runs the manager bot, our backend can technically get the managed bot token.
|
||||
```
|
||||
|
||||
Managed Bots can still be useful for convenience:
|
||||
|
||||
- Less copy/paste from BotFather.
|
||||
- Better guided creation.
|
||||
- Automatic suggested name/username.
|
||||
- Token rotation through `replaceManagedBotToken`.
|
||||
|
||||
But the privacy label should be:
|
||||
|
||||
```text
|
||||
Convenient customer-owned bot, managed by Agent Teams
|
||||
```
|
||||
|
||||
not:
|
||||
|
||||
```text
|
||||
Private token that Agent Teams cannot access
|
||||
```
|
||||
|
||||
For the clean privacy option, user should create a bot in BotFather and paste token into desktop locally, or use a future flow where a locally running manager process receives the token directly and never sends it to our backend. That local-manager flow is probably too complex for MVP.
|
||||
|
||||
## Own-Bot Binding Flow
|
||||
|
||||
Own-bot mode still needs a Telegram account/chat binding.
|
||||
|
||||
Recommended own-bot flow:
|
||||
|
||||
```text
|
||||
1. User creates bot in BotFather.
|
||||
2. User pastes token into desktop.
|
||||
3. Desktop validates getMe.
|
||||
4. Desktop stores token using a SecretStoragePort based on ApiKeyService-style safeStorage/AES fallback.
|
||||
5. Desktop checks getWebhookInfo.
|
||||
6. If webhook exists, explain conflict and ask before deleteWebhook.
|
||||
7. Desktop starts getUpdates long polling.
|
||||
8. User sends /start to their own bot.
|
||||
9. Desktop receives the update locally.
|
||||
10. Desktop asks user to confirm the Telegram account/chat.
|
||||
11. Desktop activates binding and creates topics/routes.
|
||||
```
|
||||
|
||||
Edge case:
|
||||
|
||||
```text
|
||||
getUpdates does not work while an outgoing webhook is set.
|
||||
```
|
||||
|
||||
So never silently call `deleteWebhook` for an own bot. The bot may be used elsewhere.
|
||||
|
||||
## Route Activation Rules
|
||||
|
||||
After binding, route creation should be explicit:
|
||||
|
||||
```ts
|
||||
interface MessengerRoute {
|
||||
id: string;
|
||||
bindingId: string;
|
||||
provider: 'telegram';
|
||||
teamId: string;
|
||||
teamIdentitySnapshot: {
|
||||
teamName: string;
|
||||
teamPath?: string;
|
||||
teamConfigHash?: string;
|
||||
};
|
||||
topicRef?: {
|
||||
providerChatIdHash: string;
|
||||
providerMessageThreadId: string;
|
||||
topicNameSnapshot: string;
|
||||
topicCreatedAt: string;
|
||||
};
|
||||
status: 'active' | 'disabled' | 'revoked' | 'needs_repair';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Binding is account-level.
|
||||
- Route is team-level.
|
||||
- Topic is provider-level delivery state.
|
||||
- One Telegram account can bind to multiple teams.
|
||||
- One team route maps to one Telegram topic in that account's bot chat.
|
||||
- Topic title is display metadata only. Never route by title.
|
||||
- If topic id is missing or stale, mark `needs_repair` and create a new topic after user confirmation.
|
||||
|
||||
## Multi-Team and Multi-Account Policy
|
||||
|
||||
MVP policy:
|
||||
|
||||
```text
|
||||
One Telegram account binding per desktop install.
|
||||
Many team routes under that binding.
|
||||
One topic per team route.
|
||||
```
|
||||
|
||||
Later policy:
|
||||
|
||||
```text
|
||||
Multiple Telegram accounts per install.
|
||||
Each account can opt into selected teams.
|
||||
Routes must include bindingId.
|
||||
UI can show "Connected as @alice" per route.
|
||||
```
|
||||
|
||||
Do not key route ownership only by `teamName`.
|
||||
|
||||
Use a stable team id or derived identity:
|
||||
|
||||
```text
|
||||
teamId = persisted id if available
|
||||
fallback = hash(canonical team path + creation marker)
|
||||
teamName = mutable display snapshot
|
||||
```
|
||||
|
||||
This is important because previous local research found many surfaces still use names like `teamName` and `memberName`.
|
||||
|
||||
## Threat Model and Required Controls
|
||||
|
||||
### Forwarded setup link or screenshot
|
||||
|
||||
Control:
|
||||
|
||||
```text
|
||||
Desktop confirmation is mandatory.
|
||||
```
|
||||
|
||||
The Telegram claim only moves challenge to `telegram_claimed`.
|
||||
|
||||
### Stale or replayed nonce
|
||||
|
||||
Controls:
|
||||
|
||||
```text
|
||||
TTL 5-10 minutes
|
||||
single-use challenge hash
|
||||
state transition compare-and-swap
|
||||
idempotent duplicate update handling
|
||||
generic expired response
|
||||
```
|
||||
|
||||
### Two Telegram users race the same nonce
|
||||
|
||||
Control:
|
||||
|
||||
```text
|
||||
First claim locks the challenge.
|
||||
Second distinct user marks suspicious or gets generic expired response.
|
||||
Desktop must show the first claimed display name before confirm.
|
||||
```
|
||||
|
||||
### Username changed
|
||||
|
||||
Control:
|
||||
|
||||
```text
|
||||
Never use username for identity.
|
||||
Update display snapshot from new messages.
|
||||
```
|
||||
|
||||
### Wrong chat type
|
||||
|
||||
Control:
|
||||
|
||||
```text
|
||||
Official MVP accepts only private chat with the bot.
|
||||
Group/supergroup/channel starts are rejected unless a future group-mode route is explicitly built.
|
||||
```
|
||||
|
||||
### Telegram user blocks bot
|
||||
|
||||
Control:
|
||||
|
||||
```text
|
||||
Outbound send failure transitions binding or route to provider_blocked_bot / needs_attention.
|
||||
Do not keep retrying indefinitely.
|
||||
```
|
||||
|
||||
### Desktop offline after binding
|
||||
|
||||
Control:
|
||||
|
||||
```text
|
||||
Default official mode has no durable plaintext backend queue.
|
||||
Backend replies with offline notice or "desktop unavailable".
|
||||
```
|
||||
|
||||
### Backend receives duplicate Telegram updates
|
||||
|
||||
Control:
|
||||
|
||||
```text
|
||||
ProviderUpdateLedger keyed by provider + botMode + update_id.
|
||||
Idempotent inbound message creation.
|
||||
```
|
||||
|
||||
### Backend restart during claimed-but-unconfirmed pairing
|
||||
|
||||
Control:
|
||||
|
||||
```text
|
||||
Persist pending challenge state with TTL.
|
||||
Desktop reconnect asks for current challenge status.
|
||||
```
|
||||
|
||||
### User reinstalls desktop
|
||||
|
||||
Control:
|
||||
|
||||
```text
|
||||
Install identity is local.
|
||||
If lost, existing bindings become orphaned until user reconnects.
|
||||
Offer revoke from Telegram with /disconnect.
|
||||
```
|
||||
|
||||
### Shared computer or copied config
|
||||
|
||||
Control:
|
||||
|
||||
```text
|
||||
Store install secret under app data using OS secret storage where possible.
|
||||
Copying JSON config alone should not authenticate a binding.
|
||||
```
|
||||
|
||||
### Own-bot token leaked
|
||||
|
||||
Controls:
|
||||
|
||||
```text
|
||||
SafeStorage/AES fallback
|
||||
0o600 file permissions
|
||||
masked list output
|
||||
redacted logs
|
||||
explicit token rotation and delete
|
||||
```
|
||||
|
||||
### Managed bot token fetched by our backend
|
||||
|
||||
Control:
|
||||
|
||||
```text
|
||||
Do not market Managed Bots as token-private.
|
||||
Offer "own token locally" for maximum privacy.
|
||||
```
|
||||
|
||||
## Security Storage Recommendation
|
||||
|
||||
Create feature-local ports:
|
||||
|
||||
```ts
|
||||
interface MessengerInstallIdentityStore {
|
||||
getOrCreateInstallIdentity(): Promise<MessengerInstallIdentity>;
|
||||
rotateInstallSecret(reason: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface MessengerSecretStore {
|
||||
saveSecret(ref: string, plaintext: string): Promise<void>;
|
||||
readSecret(ref: string): Promise<string | null>;
|
||||
deleteSecret(ref: string): Promise<void>;
|
||||
getStatus(): Promise<SecretStorageStatus>;
|
||||
}
|
||||
```
|
||||
|
||||
Implementation:
|
||||
|
||||
- For local desktop, adapt the existing `ApiKeyService` encryption strategy.
|
||||
- Do not import `ApiKeyService` directly into core.
|
||||
- Keep plaintext secrets out of renderer contracts.
|
||||
- Renderer gets masked status only.
|
||||
- Main process owns token validation, storage, polling, and provider calls.
|
||||
|
||||
Storage location:
|
||||
|
||||
```text
|
||||
App-owned data under getAppDataPath(), not ~/.claude/teams.
|
||||
```
|
||||
|
||||
Reason:
|
||||
|
||||
```text
|
||||
Messenger bindings are app integration state, not agent CLI/team project data.
|
||||
```
|
||||
|
||||
## Backend Data Minimization for Official Bot
|
||||
|
||||
Backend tables should separate routing metadata from message payloads.
|
||||
|
||||
Minimum default mode:
|
||||
|
||||
```text
|
||||
messenger_bindings
|
||||
binding_id
|
||||
provider
|
||||
install_id_hash
|
||||
telegram_user_id_hmac
|
||||
telegram_chat_id_ciphertext
|
||||
display_snapshot
|
||||
status
|
||||
created_at
|
||||
confirmed_at
|
||||
|
||||
messenger_routes
|
||||
route_id
|
||||
binding_id
|
||||
team_id_hash
|
||||
provider_thread_id
|
||||
status
|
||||
created_at
|
||||
updated_at
|
||||
|
||||
telegram_update_ledger
|
||||
bot_mode
|
||||
update_id
|
||||
update_type
|
||||
processed_at
|
||||
result_kind
|
||||
```
|
||||
|
||||
Avoid in default mode:
|
||||
|
||||
```text
|
||||
durable plaintext inbound bodies
|
||||
durable plaintext outbound bodies
|
||||
raw Telegram ids in logs
|
||||
raw start payloads in logs
|
||||
own-bot tokens on backend
|
||||
```
|
||||
|
||||
If we later add encrypted queue:
|
||||
|
||||
```text
|
||||
desktop public key
|
||||
backend stores ciphertext only
|
||||
desktop decrypts when online
|
||||
outbound offline queue requires explicit user opt-in
|
||||
```
|
||||
|
||||
## Connect Wizard UX
|
||||
|
||||
Recommended happy path:
|
||||
|
||||
```text
|
||||
Settings -> Messenger -> Telegram -> Connect
|
||||
|
||||
Step 1: Choose mode
|
||||
Default: Agent Teams bot
|
||||
Advanced: My own bot
|
||||
|
||||
Step 2: Select teams
|
||||
All active teams by default, editable checklist
|
||||
|
||||
Step 3: Open Telegram
|
||||
QR + button, expires countdown
|
||||
|
||||
Step 4: Confirm
|
||||
"Telegram account @alice wants to connect"
|
||||
show first name, username, provider user id suffix/hash
|
||||
|
||||
Step 5: Topics
|
||||
create one topic per selected team
|
||||
show per-team success/needs repair
|
||||
```
|
||||
|
||||
Failure UI:
|
||||
|
||||
- Link expired: one-click regenerate.
|
||||
- Wrong Telegram account claimed: cancel and regenerate.
|
||||
- Desktop offline during claim: bot says "finish setup on desktop".
|
||||
- Topic creation failed: binding can still be active, route is `needs_repair`.
|
||||
- Bot blocked: show reconnect instructions.
|
||||
|
||||
No hidden auto-activation.
|
||||
|
||||
## Clean Architecture Placement
|
||||
|
||||
Feature slice:
|
||||
|
||||
```text
|
||||
src/features/messenger-connectors/
|
||||
contracts/
|
||||
index.ts
|
||||
messengerConnectorApi.ts
|
||||
telegramDtos.ts
|
||||
core/
|
||||
domain/
|
||||
bindingState.ts
|
||||
pairingChallenge.ts
|
||||
routePolicy.ts
|
||||
providerIdentity.ts
|
||||
visibilityPolicy.ts
|
||||
application/
|
||||
ports/
|
||||
MessengerBindingStore.ts
|
||||
MessengerSecretStore.ts
|
||||
MessengerProviderGateway.ts
|
||||
MessengerDesktopSessionGateway.ts
|
||||
StartPairingUseCase.ts
|
||||
ClaimPairingUseCase.ts
|
||||
ConfirmPairingUseCase.ts
|
||||
RevokeBindingUseCase.ts
|
||||
RepairRoutesUseCase.ts
|
||||
main/
|
||||
composition/
|
||||
adapters/
|
||||
input/
|
||||
messengerIpcHandlers.ts
|
||||
telegramWebhookRoutes.ts
|
||||
output/
|
||||
TelegramOfficialBotGateway.ts
|
||||
TelegramOwnBotGateway.ts
|
||||
FileMessengerBindingStore.ts
|
||||
ElectronMessengerSecretStore.ts
|
||||
infrastructure/
|
||||
telegram/
|
||||
storage/
|
||||
crypto/
|
||||
preload/
|
||||
renderer/
|
||||
```
|
||||
|
||||
Important dependency rule:
|
||||
|
||||
```text
|
||||
Telegram API specifics are adapter details.
|
||||
Binding state, route state, replay prevention, and privacy policy are core/application rules.
|
||||
```
|
||||
|
||||
## Tests To Add Before Shipping
|
||||
|
||||
Domain/application:
|
||||
|
||||
- `startPairing` creates a challenge with TTL and hashed nonce.
|
||||
- `claimPairing` rejects unknown nonce.
|
||||
- `claimPairing` rejects expired nonce.
|
||||
- `claimPairing` is idempotent for duplicate same Telegram update.
|
||||
- `claimPairing` marks suspicious for a different user racing the same nonce.
|
||||
- `confirmPairing` fails if no Telegram claim exists.
|
||||
- `confirmPairing` activates only the claimed binding.
|
||||
- `cancelPairing` prevents later activation.
|
||||
- `revokeBinding` disables routes.
|
||||
- `routePolicy` never keys by username or topic title.
|
||||
|
||||
Adapter/integration:
|
||||
|
||||
- Telegram webhook verifies `secret_token`.
|
||||
- Telegram update ledger dedupes `update_id`.
|
||||
- `/start` in group/supergroup is rejected in MVP.
|
||||
- JS persistence stores Telegram ids as strings.
|
||||
- Raw nonce is not logged.
|
||||
- Raw own-bot token is not sent to renderer.
|
||||
- Own-bot `getWebhookInfo.url` conflict is surfaced before `deleteWebhook`.
|
||||
- `safeStorage` unavailable path still encrypts with AES fallback and file mode is restrictive.
|
||||
|
||||
End-to-end scenarios:
|
||||
|
||||
- Happy path official bot connect.
|
||||
- Forwarded link claimed by wrong Telegram account, desktop cancels, no route active.
|
||||
- Link expires, user regenerates, old link stays dead.
|
||||
- Two users race same link.
|
||||
- Desktop restarts after Telegram claim and before confirm.
|
||||
- User blocks bot after binding.
|
||||
- User revokes binding from desktop.
|
||||
- User sends `/disconnect` in Telegram.
|
||||
- Team renamed after topic exists.
|
||||
- Team route repair creates new topic without reusing title as identity.
|
||||
|
||||
## Decision Update
|
||||
|
||||
The best implementation decision after this pass:
|
||||
|
||||
```text
|
||||
Default:
|
||||
official shared bot
|
||||
desktop-originated one-time deep link
|
||||
desktop confirmation required
|
||||
no durable plaintext backend message queue
|
||||
one topic per team route
|
||||
|
||||
Fallback:
|
||||
bot-first short code entry
|
||||
|
||||
Advanced privacy:
|
||||
own bot token pasted into desktop
|
||||
token stored locally
|
||||
local getUpdates polling
|
||||
|
||||
Later:
|
||||
encrypted backend queue
|
||||
Telegram Mini App/Login identity layer
|
||||
Managed Bots only as convenience, not as "token inaccessible to us"
|
||||
```
|
||||
|
||||
Main open question left:
|
||||
|
||||
```text
|
||||
Do we want one Telegram account per desktop install for MVP,
|
||||
or allow multiple connected Telegram accounts immediately?
|
||||
```
|
||||
|
||||
My recommendation:
|
||||
|
||||
🎯 9 🛡️ 8 🧠 4 Approx change size: +600-1200 LOC compared to account-agnostic routing
|
||||
|
||||
```text
|
||||
Start with one account per install, but include bindingId in every route model.
|
||||
That keeps MVP UX simple and leaves the data model ready for multi-account later.
|
||||
```
|
||||
897
docs/research/messenger-connectors-uncertainty-pass-34.md
Normal file
897
docs/research/messenger-connectors-uncertainty-pass-34.md
Normal file
|
|
@ -0,0 +1,897 @@
|
|||
# Messenger Connectors - Uncertainty Pass 34
|
||||
|
||||
Date: 2026-04-29
|
||||
Scope: official shared bot relay transport, webhook ACK semantics, desktop online detection, no durable plaintext backend queue, and local commit guarantees
|
||||
|
||||
## Executive Delta
|
||||
|
||||
The weakest reliability boundary is:
|
||||
|
||||
```text
|
||||
Telegram webhook
|
||||
-> Agent Teams backend
|
||||
-> online desktop relay session
|
||||
-> durable local inbound message
|
||||
-> lead/team routing
|
||||
```
|
||||
|
||||
The core problem:
|
||||
|
||||
```text
|
||||
If backend returns HTTP 2xx to Telegram before the desktop durably commits the message,
|
||||
then a crash, reconnect, or dropped ACK can lose the user message forever.
|
||||
```
|
||||
|
||||
Because the default product decision is "no durable plaintext backend queue", the backend cannot solve this by storing pending message bodies until desktop returns.
|
||||
|
||||
So the default official-bot rule should be:
|
||||
|
||||
```text
|
||||
Return success to Telegram only after one of these is true:
|
||||
|
||||
1. Desktop ACKed that it durably committed the inbound message locally.
|
||||
2. Backend handled the update terminally, for example no desktop is online and an offline notice was sent.
|
||||
3. Backend intentionally rejects the webhook attempt so Telegram retries later.
|
||||
```
|
||||
|
||||
This is the exact bridge that must be designed as a protocol, not as a best-effort event bus.
|
||||
|
||||
## Source Facts Rechecked
|
||||
|
||||
Telegram official facts checked on 2026-04-29:
|
||||
|
||||
- Bot API has two mutually exclusive update delivery modes: `getUpdates` and webhooks.
|
||||
- Incoming updates are stored on Telegram servers until the bot receives them, but not longer than 24 hours.
|
||||
- `getUpdates.offset` confirms updates when the offset is greater than their `update_id`.
|
||||
- `Update.update_id` is useful for ignoring repeated updates or restoring sequence if webhook updates are out of order.
|
||||
- `setWebhook.max_connections` controls the maximum simultaneous HTTPS connections Telegram may use for webhook delivery.
|
||||
- On webhook delivery, unsuccessful requests are retried for a reasonable number of attempts.
|
||||
- `WebhookInfo.pending_update_count`, `last_error_date`, and `last_error_message` expose webhook backlog/error state.
|
||||
- `setWebhook.secret_token` adds `X-Telegram-Bot-Api-Secret-Token` to webhook requests.
|
||||
|
||||
Transport facts checked on 2026-04-29:
|
||||
|
||||
- Node.js v22 has a stable native WebSocket client API.
|
||||
- Node.js v22 does not provide a built-in WebSocket server, so a Node backend still needs a server library.
|
||||
- WebSocket is full-duplex over one connection, which fits `offer -> ACK -> control` flows.
|
||||
- Server-Sent Events are one-way server-to-client. Client ACKs require a separate HTTP request.
|
||||
- SSE supports `id` and reconnection behavior through EventSource, but it is still one-way.
|
||||
- Existing repo already uses Fastify 5.7.4. `@fastify/websocket` 11.2.0 is the current npm package for WebSocket support and is built on `ws@8`.
|
||||
- Snyk lists `ws@8.20.0` as published March 21, 2026, latest, with no direct vulnerabilities in its database at lookup time.
|
||||
|
||||
Sources:
|
||||
|
||||
- https://core.telegram.org/bots/api#getting-updates
|
||||
- https://core.telegram.org/bots/api#update
|
||||
- https://core.telegram.org/bots/api#setwebhook
|
||||
- https://core.telegram.org/bots/api#getwebhookinfo
|
||||
- https://nodejs.org/learn/getting-started/websocket
|
||||
- https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events
|
||||
- https://www.npmjs.com/package/%40fastify/websocket
|
||||
- https://security.snyk.io/package/npm/ws/8.20.0
|
||||
|
||||
Local code facts checked:
|
||||
|
||||
- Existing `HttpServer` is Fastify-based and binds to `127.0.0.1` by default for local app/browser API.
|
||||
- Existing `src/main/http/events.ts` implements SSE for local UI clients, with keepalive comments every 30 seconds.
|
||||
- That SSE stream has no durable event id/resume model and no client-to-server ACK path. It is fine for local UI refresh, not for Telegram relay commit.
|
||||
- The repo already has good local durability patterns:
|
||||
- `VersionedJsonStore.updateLocked()`
|
||||
- `atomicWriteAsync`
|
||||
- `withFileLock`
|
||||
- runtime delivery journals with payload hash, pending/committed states, idempotency keys
|
||||
- These patterns are directly relevant to the desktop-local inbound store and delivery ledger.
|
||||
|
||||
Implication:
|
||||
|
||||
```text
|
||||
Do not reuse the existing local SSE event broadcaster as the official bot relay.
|
||||
Build a dedicated MessengerRelay protocol.
|
||||
```
|
||||
|
||||
## Top 3 Relay Architecture Options
|
||||
|
||||
### 1. Desktop outbound WebSocket with local-commit ACK
|
||||
|
||||
🎯 9 🛡️ 8 🧠 7 Approx change size: 4000-8500 LOC
|
||||
|
||||
Shape:
|
||||
|
||||
```text
|
||||
desktop main process
|
||||
opens WSS connection to Agent Teams relay backend
|
||||
authenticates install/binding/session
|
||||
sends route inventory hash and heartbeat
|
||||
|
||||
backend
|
||||
receives Telegram webhook
|
||||
resolves binding/route/topic
|
||||
sends inbound offer over WebSocket
|
||||
waits for desktop local-commit ACK
|
||||
returns Telegram 2xx only after commit ACK
|
||||
```
|
||||
|
||||
Why this is best:
|
||||
|
||||
- WebSocket is bidirectional, so `offer -> ack -> cancel -> repair -> heartbeat` stays on one connection.
|
||||
- Desktop can use Node 22 native WebSocket client with no new desktop dependency.
|
||||
- Backend can use Fastify + `@fastify/websocket` if cloud backend is Node/Fastify.
|
||||
- It supports real online presence, route inventory sync, and backpressure.
|
||||
- It avoids backend durable plaintext message bodies.
|
||||
|
||||
Weaknesses:
|
||||
|
||||
- Needs a real protocol, not just "send JSON over socket".
|
||||
- Needs careful ACK timeout and reconnect behavior.
|
||||
- Backend still holds plaintext in memory during the webhook attempt.
|
||||
- Active-session ACK timeout is ambiguous: desktop might have committed but ACK was lost.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Use this for default official shared bot.
|
||||
```
|
||||
|
||||
### 2. SSE downlink plus HTTPS ACK uplink
|
||||
|
||||
🎯 7 🛡️ 7 🧠 6 Approx change size: 3500-7000 LOC
|
||||
|
||||
Shape:
|
||||
|
||||
```text
|
||||
desktop opens EventSource/SSE to backend
|
||||
backend pushes inbound offers over SSE
|
||||
desktop POSTs /ack for local commit
|
||||
desktop POSTs heartbeat/inventory separately
|
||||
```
|
||||
|
||||
Why it is attractive:
|
||||
|
||||
- SSE is simple and HTTP-friendly.
|
||||
- Browser/EventSource has reconnect behavior.
|
||||
- This resembles the repo's local `/api/events` pattern.
|
||||
|
||||
Weaknesses:
|
||||
|
||||
- SSE is one-way, so ACKs and heartbeats need extra HTTP calls.
|
||||
- Correlating SSE offer with POST ACK is more complex under reconnect.
|
||||
- Existing local SSE implementation lacks durable event ids and Last-Event-ID resume.
|
||||
- Browser SSE connection limits matter for renderer use. Desktop main process can avoid browser limits, but the protocol is still less direct than WebSocket.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Acceptable fallback if WebSocket is blocked by enterprise proxies.
|
||||
Not the primary implementation.
|
||||
```
|
||||
|
||||
### 3. Desktop polling/long-polling relay
|
||||
|
||||
🎯 6 🛡️ 6 🧠 4 Approx change size: 2200-5000 LOC
|
||||
|
||||
Shape:
|
||||
|
||||
```text
|
||||
desktop polls backend for pending inbound updates
|
||||
backend returns message bodies if any
|
||||
desktop commits locally and POSTs ACK
|
||||
```
|
||||
|
||||
Why it is attractive:
|
||||
|
||||
- Easier to reason about than long-lived sockets.
|
||||
- Works in many locked-down networks.
|
||||
- Simple to implement initially.
|
||||
|
||||
Weaknesses:
|
||||
|
||||
- To avoid message loss, backend must hold pending plaintext while waiting for poll.
|
||||
- If backend refuses durable plaintext queue, polling becomes either lossy or high-frequency.
|
||||
- Latency is worse.
|
||||
- Online/offline state becomes fuzzy.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Not recommended for default no-plaintext-queue mode.
|
||||
Can be a diagnostics fallback, not the main relay.
|
||||
```
|
||||
|
||||
## Future Reliability Option
|
||||
|
||||
### Durable encrypted backend queue
|
||||
|
||||
🎯 8 🛡️ 9 🧠 9 Approx change size: 7000-14000 LOC
|
||||
|
||||
Shape:
|
||||
|
||||
```text
|
||||
desktop publishes public encryption key during binding
|
||||
backend stores only ciphertext message bodies
|
||||
desktop decrypts when online
|
||||
backend can survive restarts and desktop offline windows
|
||||
```
|
||||
|
||||
This is the right advanced/premium reliability mode, but it is not the MVP default.
|
||||
|
||||
Why:
|
||||
|
||||
- Key rotation is non-trivial.
|
||||
- Device loss/reinstall can make queued messages undecryptable.
|
||||
- Multi-device routing becomes harder.
|
||||
- Attachments need a separate encrypted blob policy.
|
||||
- User copy must explain exactly who can decrypt what.
|
||||
|
||||
## Recommended Default Protocol
|
||||
|
||||
### High-level flow
|
||||
|
||||
```text
|
||||
Telegram -> Backend webhook
|
||||
1. verify webhook secret_token
|
||||
2. dedupe update_id metadata
|
||||
3. resolve binding/route/topic
|
||||
4. check active desktop relay session
|
||||
5. if no healthy session: send offline notice, return 2xx
|
||||
6. if healthy session: offer update to desktop
|
||||
7. desktop validates route and commits local inbound message
|
||||
8. desktop ACKs local commit
|
||||
9. backend returns 2xx to Telegram
|
||||
```
|
||||
|
||||
The backend durable metadata ledger may store:
|
||||
|
||||
```text
|
||||
provider
|
||||
bot mode
|
||||
update_id
|
||||
route id
|
||||
binding id
|
||||
attempt count
|
||||
status
|
||||
timestamps
|
||||
error class
|
||||
payload hash
|
||||
```
|
||||
|
||||
It should not store in default mode:
|
||||
|
||||
```text
|
||||
raw message body
|
||||
raw Telegram chat id in logs
|
||||
raw Telegram user id in logs
|
||||
attachment file bodies
|
||||
bot tokens
|
||||
```
|
||||
|
||||
### Webhook ACK invariant
|
||||
|
||||
```text
|
||||
Telegram 2xx means:
|
||||
Agent Teams either got the message durably into desktop local storage,
|
||||
or intentionally terminal-handled it, for example offline notice.
|
||||
```
|
||||
|
||||
Telegram non-2xx means:
|
||||
|
||||
```text
|
||||
Agent Teams has not accepted responsibility for the update.
|
||||
Telegram may retry the same update later.
|
||||
```
|
||||
|
||||
This must be an explicit code invariant.
|
||||
|
||||
## Active Session Definition
|
||||
|
||||
Do not define "online" as "there is a socket object".
|
||||
|
||||
Define it as:
|
||||
|
||||
```ts
|
||||
interface MessengerRelaySession {
|
||||
sessionId: string;
|
||||
installId: string;
|
||||
bindingId: string;
|
||||
authenticatedAt: string;
|
||||
lastHeartbeatAt: string;
|
||||
lastPongAt: string;
|
||||
routeInventoryHash: string;
|
||||
protocolVersion: number;
|
||||
status: 'ready' | 'stale' | 'draining' | 'closed';
|
||||
}
|
||||
```
|
||||
|
||||
A session is healthy only if:
|
||||
|
||||
```text
|
||||
status == ready
|
||||
authenticated install secret is valid
|
||||
binding is active
|
||||
route inventory hash is current or compatible
|
||||
last pong is recent
|
||||
desktop protocol version is supported
|
||||
no newer session has stolen the lease
|
||||
```
|
||||
|
||||
Suggested timing:
|
||||
|
||||
```text
|
||||
ping interval: 15s
|
||||
stale after: 45s
|
||||
hard close after: 75s
|
||||
inbound offer ACK deadline: 3-8s
|
||||
```
|
||||
|
||||
Use jitter for reconnect:
|
||||
|
||||
```text
|
||||
initial reconnect: 1s
|
||||
max reconnect: 30s
|
||||
jitter: 20-40 percent
|
||||
```
|
||||
|
||||
## Single Active Session Lease
|
||||
|
||||
MVP should allow one active relay session per binding.
|
||||
|
||||
Rule:
|
||||
|
||||
```text
|
||||
New authenticated session for the same bindingId steals the lease.
|
||||
Old session transitions to draining/closed and cannot ACK new offers.
|
||||
```
|
||||
|
||||
Why:
|
||||
|
||||
- Prevents two desktop processes writing the same Telegram update to different local stores.
|
||||
- Avoids split-brain if user launches two app instances.
|
||||
- Keeps support/debugging simpler.
|
||||
|
||||
Later multi-device mode can use:
|
||||
|
||||
```text
|
||||
bindingId + deviceId + route assignment
|
||||
```
|
||||
|
||||
But do not start there.
|
||||
|
||||
## Inbound Offer Envelope
|
||||
|
||||
Provider-neutral envelope:
|
||||
|
||||
```ts
|
||||
interface MessengerInboundOffer {
|
||||
type: 'messenger.inbound.offer';
|
||||
protocolVersion: 1;
|
||||
deliveryId: string;
|
||||
provider: 'telegram';
|
||||
bindingId: string;
|
||||
routeId: string;
|
||||
orderingKey: string;
|
||||
providerUpdateId: string;
|
||||
providerMessageId?: string;
|
||||
providerMessageThreadId?: string;
|
||||
providerDate?: string;
|
||||
receivedAt: string;
|
||||
expiresAt: string;
|
||||
payloadHash: string;
|
||||
payload: MessengerInboundPayload;
|
||||
}
|
||||
```
|
||||
|
||||
Payload:
|
||||
|
||||
```ts
|
||||
interface MessengerInboundPayload {
|
||||
kind: 'text' | 'command' | 'unsupported';
|
||||
text?: string;
|
||||
replyTo?: ProviderMessageLink;
|
||||
sender: {
|
||||
providerUserIdHash: string;
|
||||
displayNameSnapshot: string;
|
||||
usernameSnapshot?: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Desktop ACK:
|
||||
|
||||
```ts
|
||||
type MessengerInboundAck =
|
||||
| {
|
||||
type: 'messenger.inbound.ack';
|
||||
deliveryId: string;
|
||||
status: 'committed';
|
||||
localMessageId: string;
|
||||
localCommitHash: string;
|
||||
committedAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'messenger.inbound.ack';
|
||||
deliveryId: string;
|
||||
status: 'duplicate_committed';
|
||||
localMessageId: string;
|
||||
committedAt: string;
|
||||
}
|
||||
| {
|
||||
type: 'messenger.inbound.ack';
|
||||
deliveryId: string;
|
||||
status: 'rejected_terminal' | 'rejected_retryable';
|
||||
reasonCode: string;
|
||||
detail?: string;
|
||||
};
|
||||
```
|
||||
|
||||
## Desktop Local Commit Rules
|
||||
|
||||
Desktop must ACK `committed` only after:
|
||||
|
||||
```text
|
||||
1. Provider update id was deduped locally.
|
||||
2. Route binding is still active.
|
||||
3. Message payload passed visibility/safety validation.
|
||||
4. Message was written to a local durable inbound store.
|
||||
5. Local store fsync/atomic-write equivalent completed as far as our platform layer supports.
|
||||
```
|
||||
|
||||
Recommended stores:
|
||||
|
||||
```text
|
||||
MessengerDesktopInboundStore
|
||||
durable provider update payloads after acceptance
|
||||
|
||||
MessengerLocalDeliveryLedger
|
||||
tracks delivery from inbound store to lead/team inbox
|
||||
|
||||
MessengerProviderUpdateLedger
|
||||
dedupes providerUpdateId locally
|
||||
```
|
||||
|
||||
Do not ACK based on:
|
||||
|
||||
```text
|
||||
renderer state update
|
||||
toast notification shown
|
||||
in-memory queue push only
|
||||
lead process prompt accepted but not persisted
|
||||
```
|
||||
|
||||
## Backend Webhook Decision Matrix
|
||||
|
||||
### No active session
|
||||
|
||||
```text
|
||||
send offline notice
|
||||
return Telegram 2xx
|
||||
record metadata: terminal_offline
|
||||
```
|
||||
|
||||
This matches the current product decision:
|
||||
|
||||
```text
|
||||
desktop offline -> no plaintext queue -> honest offline response
|
||||
```
|
||||
|
||||
### Active session, offer ACKed committed
|
||||
|
||||
```text
|
||||
return Telegram 2xx
|
||||
record metadata: desktop_committed
|
||||
```
|
||||
|
||||
### Active session, duplicate committed ACK
|
||||
|
||||
```text
|
||||
return Telegram 2xx
|
||||
record metadata: duplicate_desktop_committed
|
||||
```
|
||||
|
||||
### Active session, terminal reject
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
route revoked
|
||||
unknown topic
|
||||
unsupported chat type
|
||||
payload rejected by policy
|
||||
```
|
||||
|
||||
Action:
|
||||
|
||||
```text
|
||||
send user-facing rejection if useful
|
||||
return Telegram 2xx
|
||||
record metadata: terminal_rejected
|
||||
```
|
||||
|
||||
### Active session, retryable reject
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
local store locked
|
||||
team route repairing
|
||||
desktop still loading route inventory
|
||||
```
|
||||
|
||||
Action:
|
||||
|
||||
```text
|
||||
return Telegram 503 for a bounded number of attempts or bounded age
|
||||
then fall back to offline/degraded notice and 2xx
|
||||
```
|
||||
|
||||
### Active session, no ACK before deadline
|
||||
|
||||
This is the hardest case.
|
||||
|
||||
Recommended:
|
||||
|
||||
```text
|
||||
1. Mark metadata: ack_timeout_ambiguous.
|
||||
2. Return Telegram 503 if within retry budget.
|
||||
3. On retry, re-offer with same providerUpdateId and payloadHash.
|
||||
4. Desktop must return duplicate_committed if it already wrote the message.
|
||||
5. If retry budget expires, send "delivery uncertain/offline" notice and return 2xx.
|
||||
```
|
||||
|
||||
Do not immediately send a definitive "not delivered" notice after an ACK timeout, because the desktop might have committed and the ACK may have been lost.
|
||||
|
||||
## Retry Budget
|
||||
|
||||
Use Telegram retries only for ambiguous transient failures, not as a product queue.
|
||||
|
||||
Suggested initial policy:
|
||||
|
||||
```text
|
||||
max retry deferrals per update: 2
|
||||
max retry window: 30-60s
|
||||
if no healthy desktop by then: offline/degraded notice and 2xx
|
||||
```
|
||||
|
||||
Why:
|
||||
|
||||
- Keeps the product promise: no default durable backend plaintext queue.
|
||||
- Avoids indefinite webhook backlog.
|
||||
- Lets short reconnects recover.
|
||||
- Does not silently turn Telegram into a long-term queue.
|
||||
|
||||
## Ordering Rules
|
||||
|
||||
Telegram `update_id` is useful for duplicate detection, but do not assume every update id is contiguous forever.
|
||||
|
||||
Route ordering should use:
|
||||
|
||||
```text
|
||||
orderingKey = provider + bindingId + routeId
|
||||
providerOrder = update_id plus provider message date/message_id when available
|
||||
```
|
||||
|
||||
Backend:
|
||||
|
||||
```text
|
||||
Use an in-memory per-route serial executor while the process is alive.
|
||||
Do not persist plaintext to achieve ordering in default mode.
|
||||
```
|
||||
|
||||
Desktop:
|
||||
|
||||
```text
|
||||
Deduplicate by providerUpdateId.
|
||||
Append accepted messages in provider order when possible.
|
||||
If out-of-order arrival is detected, store both and mark ordering warning.
|
||||
```
|
||||
|
||||
Webhook setting:
|
||||
|
||||
```text
|
||||
Do not set max_connections to 1 globally for the shared bot unless traffic is tiny.
|
||||
Use route-level ordering instead.
|
||||
```
|
||||
|
||||
Reason:
|
||||
|
||||
```text
|
||||
max_connections=1 would serialize every customer through one webhook lane.
|
||||
That is safe but does not scale.
|
||||
```
|
||||
|
||||
## Route Inventory Handshake
|
||||
|
||||
When desktop connects:
|
||||
|
||||
```text
|
||||
1. authenticate install/binding
|
||||
2. send protocol version
|
||||
3. send route inventory hash
|
||||
4. backend responds with active routes known server-side
|
||||
5. desktop responds with local route inventory
|
||||
6. both sides mark compatible or needs_repair
|
||||
```
|
||||
|
||||
If route inventory mismatches:
|
||||
|
||||
```text
|
||||
do not deliver inbound user messages into uncertain routes
|
||||
ask desktop to repair or refresh
|
||||
```
|
||||
|
||||
This protects cases like:
|
||||
|
||||
- team deleted locally
|
||||
- team renamed
|
||||
- topic recreated
|
||||
- binding revoked on another process
|
||||
- local route store restored from old backup
|
||||
|
||||
## Desktop To Lead Delivery
|
||||
|
||||
The desktop local commit should not directly mean "agent saw it".
|
||||
|
||||
Better state split:
|
||||
|
||||
```text
|
||||
provider update accepted locally
|
||||
-> local inbound message committed
|
||||
-> route to lead/team inbox scheduled
|
||||
-> inbox write committed
|
||||
-> agent turn started
|
||||
-> response captured
|
||||
-> outbound provider delivery ledger
|
||||
```
|
||||
|
||||
If inbox write fails after ACKing Telegram:
|
||||
|
||||
```text
|
||||
The message is not lost because it is in MessengerDesktopInboundStore.
|
||||
MessengerLocalDeliveryLedger can retry delivery to lead/team inbox.
|
||||
```
|
||||
|
||||
This is the same reliability style as existing runtime delivery journals.
|
||||
|
||||
## Official Bot vs Own Bot Difference
|
||||
|
||||
Official shared bot default:
|
||||
|
||||
```text
|
||||
Telegram sends webhooks to our backend.
|
||||
Backend must decide quickly whether desktop accepted the update.
|
||||
If desktop is offline, backend sends offline notice and ACKs Telegram.
|
||||
No catch-up after offline notice.
|
||||
```
|
||||
|
||||
Own bot local mode:
|
||||
|
||||
```text
|
||||
Desktop can use getUpdates long polling directly.
|
||||
If desktop is offline, Telegram may retain updates for up to 24 hours.
|
||||
When desktop returns, it can catch up, because Telegram is the queue.
|
||||
```
|
||||
|
||||
This means own-bot mode has a surprising reliability advantage:
|
||||
|
||||
```text
|
||||
It can support Telegram-side catch-up without our backend storing plaintext.
|
||||
```
|
||||
|
||||
But UX must say it clearly:
|
||||
|
||||
```text
|
||||
Own bot can catch up recent Telegram updates while your computer was asleep, subject to Telegram retention.
|
||||
Default Agent Teams bot replies offline instead of queueing by default.
|
||||
```
|
||||
|
||||
## Technology Recommendation
|
||||
|
||||
### Desktop main process client
|
||||
|
||||
Use Node 22 native WebSocket client.
|
||||
|
||||
🎯 9 🛡️ 8 🧠 4 Approx change size: 700-1400 LOC
|
||||
|
||||
Why:
|
||||
|
||||
- No new dependency for desktop client.
|
||||
- Node docs say v22.4.0 marked WebSocket stable.
|
||||
- Full-duplex fits ACK/control messages.
|
||||
|
||||
### Backend WebSocket server
|
||||
|
||||
If backend is Node/Fastify, use `@fastify/websocket` 11.2.0.
|
||||
|
||||
🎯 8 🛡️ 8 🧠 5 Approx change size: 900-1800 LOC backend-side
|
||||
|
||||
Why:
|
||||
|
||||
- Aligns with existing Fastify stack style.
|
||||
- Built on `ws@8`.
|
||||
- Has TypeScript declarations.
|
||||
|
||||
Note:
|
||||
|
||||
```text
|
||||
This dependency is for cloud/backend package, not necessarily this Electron app package.
|
||||
```
|
||||
|
||||
### Fallback transport
|
||||
|
||||
Keep SSE + HTTPS ACK as an optional enterprise fallback.
|
||||
|
||||
🎯 7 🛡️ 7 🧠 6 Approx change size: +1200-2500 LOC after WebSocket protocol exists
|
||||
|
||||
Why:
|
||||
|
||||
- Some networks/proxies break WebSocket.
|
||||
- SSE is easier to pass through HTTP infrastructure.
|
||||
|
||||
But:
|
||||
|
||||
```text
|
||||
Do not implement fallback until WebSocket protocol semantics are stable.
|
||||
Otherwise two transports will double the bug surface.
|
||||
```
|
||||
|
||||
## Error Copy Policy
|
||||
|
||||
Telegram user-facing responses should be honest and short.
|
||||
|
||||
No desktop session:
|
||||
|
||||
```text
|
||||
Agent Teams desktop is offline for this team. Open the app and resend your message.
|
||||
```
|
||||
|
||||
Route disabled:
|
||||
|
||||
```text
|
||||
This team is no longer connected to Telegram. Reconnect it in Agent Teams.
|
||||
```
|
||||
|
||||
Delivery uncertain after retry budget:
|
||||
|
||||
```text
|
||||
Agent Teams could not confirm delivery to desktop. Check the app or resend.
|
||||
```
|
||||
|
||||
Unsupported media in MVP:
|
||||
|
||||
```text
|
||||
This Telegram connection currently supports text only. Send the details as text.
|
||||
```
|
||||
|
||||
Avoid:
|
||||
|
||||
```text
|
||||
"Message delivered" before desktop commit ACK.
|
||||
"Queued" in default mode.
|
||||
"We will process this when online" in default mode.
|
||||
```
|
||||
|
||||
## Security Rules
|
||||
|
||||
Relay authentication:
|
||||
|
||||
```text
|
||||
desktop signs session start with install secret
|
||||
backend issues short-lived relay session token
|
||||
WebSocket uses WSS only
|
||||
old session token cannot ACK after lease is stolen
|
||||
ACK includes deliveryId and sessionId
|
||||
```
|
||||
|
||||
Frame validation:
|
||||
|
||||
```text
|
||||
max payload size for text MVP
|
||||
strict JSON object shape
|
||||
protocolVersion required
|
||||
unknown frame types rejected
|
||||
provider ids stored as strings
|
||||
raw provider ids redacted in logs
|
||||
```
|
||||
|
||||
Replay controls:
|
||||
|
||||
```text
|
||||
providerUpdateId dedupe on backend metadata ledger
|
||||
providerUpdateId dedupe on desktop local ledger
|
||||
deliveryId unique per backend offer
|
||||
payloadHash conflict detection
|
||||
duplicate committed ACK path
|
||||
```
|
||||
|
||||
## Edge Cases To Test
|
||||
|
||||
Webhook and ACK:
|
||||
|
||||
- Telegram webhook with valid secret token and active desktop returns 2xx only after desktop commit ACK.
|
||||
- Telegram webhook with no active desktop sends offline notice and returns 2xx.
|
||||
- Active desktop socket exists but heartbeat is stale, backend treats it offline.
|
||||
- Desktop commits locally but ACK response is lost, retry returns duplicate committed.
|
||||
- Desktop receives offer after `expiresAt`, rejects retryable or terminal by policy.
|
||||
- Backend process crashes before returning 2xx, Telegram retries.
|
||||
- Backend process crashes after returning 2xx but before metadata update, metadata repair handles it.
|
||||
|
||||
Ordering and duplicates:
|
||||
|
||||
- Same `update_id` delivered twice.
|
||||
- Two updates for same route arrive concurrently.
|
||||
- Out-of-order updates due to parallel webhook connections.
|
||||
- Update id jumps after a long quiet period.
|
||||
- Payload hash conflict for same update id.
|
||||
|
||||
Session lifecycle:
|
||||
|
||||
- Second desktop instance steals lease.
|
||||
- Old session tries to ACK after lease stolen.
|
||||
- Desktop reconnects with old route inventory hash.
|
||||
- Binding revoked while socket is open.
|
||||
- Route disabled while offer is in flight.
|
||||
|
||||
Local delivery:
|
||||
|
||||
- Desktop commits inbound message, then app crashes before writing team inbox.
|
||||
- Local delivery ledger retries inbox write on restart.
|
||||
- Inbox path locked temporarily.
|
||||
- Team deleted after local commit.
|
||||
- Lead process offline after local commit.
|
||||
|
||||
Privacy:
|
||||
|
||||
- Backend durable stores contain no plaintext message bodies in default mode.
|
||||
- Logs redact raw Telegram ids and message text.
|
||||
- Offline notice path does not persist message body.
|
||||
- Metrics count event classes without payload.
|
||||
|
||||
Own bot:
|
||||
|
||||
- Desktop has no webhook and polls with `getUpdates`.
|
||||
- Existing webhook on own bot is detected and not deleted silently.
|
||||
- Desktop catches up updates after restart within Telegram retention window.
|
||||
- Desktop handles updates older than local route creation as ignored.
|
||||
|
||||
## Decision Update
|
||||
|
||||
The feature should introduce:
|
||||
|
||||
```text
|
||||
MessengerRelaySessionManager
|
||||
MessengerRelayProtocol
|
||||
MessengerBackendUpdateMetadataLedger
|
||||
MessengerDesktopInboundStore
|
||||
MessengerLocalDeliveryLedger
|
||||
```
|
||||
|
||||
Recommended default:
|
||||
|
||||
```text
|
||||
official shared bot
|
||||
WebSocket desktop relay
|
||||
local-commit ACK before Telegram 2xx
|
||||
offline notice when no healthy desktop session
|
||||
bounded Telegram retry only for ambiguous active-session failures
|
||||
no durable plaintext backend queue
|
||||
```
|
||||
|
||||
Main open uncertainty left after this pass:
|
||||
|
||||
```text
|
||||
Should official shared bot use limited Telegram webhook retries for active-session ACK timeouts,
|
||||
or always terminal-handle ambiguous timeouts with "delivery uncertain" and 2xx?
|
||||
```
|
||||
|
||||
My current recommendation:
|
||||
|
||||
🎯 8 🛡️ 8 🧠 6 Approx change size: +500-1200 LOC
|
||||
|
||||
```text
|
||||
Use limited retry deferral for active-session ACK timeouts only.
|
||||
Never use retry deferral when there is clearly no healthy desktop session.
|
||||
```
|
||||
|
||||
Reason:
|
||||
|
||||
```text
|
||||
This recovers short reconnects and ACK-loss cases without turning default mode into a hidden queue.
|
||||
```
|
||||
965
docs/research/messenger-connectors-uncertainty-pass-35.md
Normal file
965
docs/research/messenger-connectors-uncertainty-pass-35.md
Normal file
|
|
@ -0,0 +1,965 @@
|
|||
# Messenger Connectors - Uncertainty Pass 35
|
||||
|
||||
Date: 2026-04-29
|
||||
Scope: conversation history, Telegram topic projection, teammate-visible messages, backfill policy, canonical local store, and anti-duplication rules
|
||||
|
||||
## Executive Delta
|
||||
|
||||
The next weakest area is:
|
||||
|
||||
```text
|
||||
local app messages
|
||||
-> canonical messenger conversation history
|
||||
-> Telegram topic projection
|
||||
-> provider message links
|
||||
-> reply-to routing
|
||||
```
|
||||
|
||||
This looks like a UX problem, but it is actually a data model problem.
|
||||
|
||||
If we simply mirror the existing app feed into Telegram, we risk:
|
||||
|
||||
```text
|
||||
1. Sending internal lead thoughts or slash command output to Telegram.
|
||||
2. Mixing unrelated teammate replies from inboxes/user.json into the wrong topic.
|
||||
3. Duplicating the same answer because the local UI feed dedupes differently from provider delivery.
|
||||
4. Losing reply-to routing because local messages have no provider message link.
|
||||
5. Creating a Telegram topic that looks like history, but is missing context from before connection.
|
||||
6. Backfilling old history and accidentally exposing private/internal messages.
|
||||
```
|
||||
|
||||
The safest rule:
|
||||
|
||||
```text
|
||||
Telegram topic is a projection, not the source of truth.
|
||||
```
|
||||
|
||||
Canonical history must be a new provider-neutral store:
|
||||
|
||||
```text
|
||||
MessengerConversationStore
|
||||
accepted inbound provider messages
|
||||
external-safe local replies
|
||||
provider delivery links
|
||||
route/team/member references
|
||||
projection state
|
||||
```
|
||||
|
||||
The existing `TeamMessageFeedService` is useful as an input, but it is not safe to use as the Telegram projection source directly.
|
||||
|
||||
## Source Facts Rechecked
|
||||
|
||||
Telegram official facts checked on 2026-04-29:
|
||||
|
||||
- Bot API exposes update delivery through `getUpdates` or webhooks. Updates are stored on Telegram servers until the bot receives them, but not longer than 24 hours.
|
||||
- `Update.update_id` helps ignore repeated updates or restore order if webhook updates arrive out of order.
|
||||
- `Message.message_id` is unique inside a chat. In some scheduled-message cases it can be `0` and unusable until actually sent.
|
||||
- `Message.message_thread_id` identifies a message thread or forum topic for supergroups and private chats.
|
||||
- `createForumTopic` can create a topic in a forum supergroup or a private chat with a user. It returns a `ForumTopic`.
|
||||
- `editForumTopic` can change topic name/icon in a forum supergroup or private chat with a user.
|
||||
- `copyMessages` supports `message_thread_id`, copies 1-100 known messages, and returns `MessageId[]`.
|
||||
- `sendMessage` and media methods return the sent `Message` on success. This returned provider message id is required for future reply-to routing.
|
||||
- `sendChatAction` supports `message_thread_id` and lasts 5 seconds or less. Telegram recommends it only when a response will take noticeable time.
|
||||
- `sendMessageDraft` can stream a partial message to a user while it is being generated, with optional `message_thread_id`.
|
||||
- `editMessageText` can edit messages, but it is primarily for changing existing message history and has 48-hour limits for certain business messages not sent by the bot.
|
||||
- `deleteMessage` has important limits, including a 48-hour deletion window for normal messages and service-message exceptions.
|
||||
- Telegram FAQ says bots can see messages sent to them, and group privacy mode changes what group messages they can see. Treat bots as third-party participants.
|
||||
|
||||
Sources:
|
||||
|
||||
- https://core.telegram.org/bots/api#getting-updates
|
||||
- https://core.telegram.org/bots/api#update
|
||||
- https://core.telegram.org/bots/api#message
|
||||
- https://core.telegram.org/bots/api#createforumtopic
|
||||
- https://core.telegram.org/bots/api#editforumtopic
|
||||
- https://core.telegram.org/bots/api#copymessages
|
||||
- https://core.telegram.org/bots/api#sendmessage
|
||||
- https://core.telegram.org/bots/api#sendchataction
|
||||
- https://core.telegram.org/bots/api#sendmessagedraft
|
||||
- https://core.telegram.org/bots/api#editmessagetext
|
||||
- https://core.telegram.org/bots/api#deletemessage
|
||||
- https://telegram.org/faq
|
||||
|
||||
Inference from the Bot API docs:
|
||||
|
||||
```text
|
||||
The Bot API is update-driven and method-driven.
|
||||
It does not document a general "read arbitrary private chat history" method for bots.
|
||||
Therefore Agent Teams must persist the history it needs at acceptance/projection time.
|
||||
```
|
||||
|
||||
Local code facts checked:
|
||||
|
||||
- `TeamInboxReader` merges all `inboxes/*.json`, assigns `to` from the filename when absent, and creates deterministic message ids for rows without `messageId`.
|
||||
- `TeamSentMessagesStore` keeps only the newest 200 messages in `sentMessages.json`. This is a UI/local persistence cap, not a long-term external conversation history.
|
||||
- `TeamMessageFeedService` merges inbox messages, lead session messages, and sent messages, then dedupes, links passive summaries, attaches lead session ids, and annotates slash command responses.
|
||||
- `TeamMessageFeedService` is optimized for UI display, not for provider delivery or privacy policy.
|
||||
- `InboxMessage.source` already has multiple categories: `inbox`, `lead_session`, `lead_process`, `runtime_delivery`, `user_sent`, `system_notification`, `cross_team`, `cross_team_sent`.
|
||||
- Existing `conversationId` and `replyToConversationId` are used for cross-team routing and can inspire messenger conversation identity, but they are not enough by themselves for Telegram provider links.
|
||||
- `inboxes/user.json` can contain teammate replies to the user without stable provider thread context.
|
||||
|
||||
Implication:
|
||||
|
||||
```text
|
||||
Messenger history must not be derived lazily from the renderer feed.
|
||||
It must be committed as a conversation ledger when an external route is involved.
|
||||
```
|
||||
|
||||
## Top 3 History Models
|
||||
|
||||
### 1. Canonical MessengerConversationStore plus Telegram projection ledger
|
||||
|
||||
🎯 9 🛡️ 9 🧠 7 Approx change size: 4000-9000 LOC
|
||||
|
||||
Shape:
|
||||
|
||||
```text
|
||||
provider inbound committed locally
|
||||
-> MessengerConversationStore append inbound
|
||||
-> local delivery to lead/team
|
||||
-> safe local replies appended to same conversation
|
||||
-> TelegramProjectionLedger sends only eligible projection events
|
||||
-> provider message ids stored as ProviderMessageLink
|
||||
```
|
||||
|
||||
Why this is best:
|
||||
|
||||
- Telegram topic is a view of an external conversation, not the data source.
|
||||
- Existing UI feed remains untouched for local app semantics.
|
||||
- Provider delivery idempotency and reply-to mapping have a durable home.
|
||||
- Future WhatsApp/Discord adapters can reuse the same core model.
|
||||
- Privacy policy can be enforced before a row becomes externally projectable.
|
||||
|
||||
Weaknesses:
|
||||
|
||||
- More code.
|
||||
- Needs migration/UI integration to show messenger conversations.
|
||||
- Requires careful linking from existing team replies to the correct conversation.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Use this.
|
||||
```
|
||||
|
||||
### 2. Reuse existing TeamMessageFeedService as canonical history
|
||||
|
||||
🎯 5 🛡️ 4 🧠 3 Approx change size: 900-2200 LOC
|
||||
|
||||
Shape:
|
||||
|
||||
```text
|
||||
watch TeamMessageFeedService
|
||||
filter messages
|
||||
send eligible messages to Telegram topic
|
||||
store provider links separately
|
||||
```
|
||||
|
||||
Why it is tempting:
|
||||
|
||||
- Much less new architecture.
|
||||
- UI already displays this feed.
|
||||
- Existing refresh/invalidation paths exist.
|
||||
|
||||
Why it is risky:
|
||||
|
||||
- Feed is display-oriented and merges many sources.
|
||||
- It can annotate slash command responses.
|
||||
- It dedupes and links passive summaries for UI purposes.
|
||||
- It includes local-only concepts that should never leave the app by default.
|
||||
- It has no long-term guarantee because `sentMessages.json` caps at 200 rows.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Do not use as provider source of truth.
|
||||
Can be an input to a projection gate only.
|
||||
```
|
||||
|
||||
### 3. Telegram topic as the canonical history
|
||||
|
||||
🎯 4 🛡️ 5 🧠 5 Approx change size: 1800-4500 LOC
|
||||
|
||||
Shape:
|
||||
|
||||
```text
|
||||
send everything important to Telegram
|
||||
use Telegram topic message ids as history
|
||||
local app reads/links only provider ids
|
||||
```
|
||||
|
||||
Why it is attractive:
|
||||
|
||||
- User sees history in Telegram.
|
||||
- Less local history UI work.
|
||||
|
||||
Why it fails:
|
||||
|
||||
- Bot API does not provide a general documented way to read arbitrary private chat history later.
|
||||
- If delivery to Telegram is ambiguous, local source of truth is unclear.
|
||||
- If user deletes messages or blocks bot, local product history degrades.
|
||||
- Provider-specific semantics leak into core.
|
||||
- WhatsApp/Discord will not match exactly.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Reject for core architecture.
|
||||
Telegram is projection only.
|
||||
```
|
||||
|
||||
## Recommended Canonical Model
|
||||
|
||||
Use two related ledgers:
|
||||
|
||||
```text
|
||||
MessengerConversationStore
|
||||
what happened in the external-user conversation
|
||||
|
||||
MessengerProviderProjectionLedger
|
||||
what was attempted/sent/linked in Telegram
|
||||
```
|
||||
|
||||
Conversation row:
|
||||
|
||||
```ts
|
||||
interface MessengerConversationMessage {
|
||||
id: string;
|
||||
conversationId: string;
|
||||
routeId: string;
|
||||
bindingId: string;
|
||||
teamId: string;
|
||||
direction: 'inbound_from_user' | 'outbound_to_user' | 'internal_note';
|
||||
author: {
|
||||
kind: 'external_user' | 'team_member' | 'team_lead' | 'system';
|
||||
memberId?: string;
|
||||
displayName: string;
|
||||
};
|
||||
text: string;
|
||||
createdAt: string;
|
||||
externalVisibility:
|
||||
| 'projectable'
|
||||
| 'local_only'
|
||||
| 'blocked_by_policy'
|
||||
| 'requires_manual_approval';
|
||||
source: {
|
||||
kind:
|
||||
| 'telegram_update'
|
||||
| 'team_inbox'
|
||||
| 'lead_session'
|
||||
| 'runtime_delivery'
|
||||
| 'manual_ui'
|
||||
| 'system';
|
||||
localMessageId?: string;
|
||||
providerUpdateId?: string;
|
||||
providerMessageId?: string;
|
||||
leadSessionId?: string;
|
||||
};
|
||||
replyTo?: {
|
||||
conversationMessageId?: string;
|
||||
providerMessageLink?: ProviderMessageLink;
|
||||
localMessageId?: string;
|
||||
};
|
||||
policy: {
|
||||
sanitized: boolean;
|
||||
strippedInternalBlocks: boolean;
|
||||
reasonCodes: string[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Projection row:
|
||||
|
||||
```ts
|
||||
interface MessengerProviderProjectionRecord {
|
||||
id: string;
|
||||
conversationMessageId: string;
|
||||
provider: 'telegram';
|
||||
routeId: string;
|
||||
providerTarget: {
|
||||
chatIdHash: string;
|
||||
messageThreadId: string;
|
||||
};
|
||||
status:
|
||||
| 'pending'
|
||||
| 'sending'
|
||||
| 'sent'
|
||||
| 'ambiguous'
|
||||
| 'failed_retryable'
|
||||
| 'failed_terminal'
|
||||
| 'suppressed';
|
||||
payloadHash: string;
|
||||
providerMessageLink?: ProviderMessageLink;
|
||||
attempts: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastError?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Important:
|
||||
|
||||
```text
|
||||
The conversation store can contain local-only rows.
|
||||
The projection ledger can only contain rows that passed external visibility policy.
|
||||
```
|
||||
|
||||
## What Counts As Conversation History
|
||||
|
||||
For Telegram user-facing history, include:
|
||||
|
||||
```text
|
||||
1. User inbound messages accepted from Telegram.
|
||||
2. Lead replies explicitly addressed to user.
|
||||
3. Teammate replies explicitly addressed to user.
|
||||
4. User manual messages from local UI that are intentionally sent to the team under this route.
|
||||
5. Short system status messages that are explicitly external-facing, for example "desktop offline".
|
||||
```
|
||||
|
||||
Do not include by default:
|
||||
|
||||
```text
|
||||
lead thoughts
|
||||
tool summaries
|
||||
slash command outputs
|
||||
task status notifications
|
||||
cross-team internal messages
|
||||
teammate-to-teammate chat
|
||||
permission_request JSON
|
||||
idle heartbeats
|
||||
bootstrap check-ins
|
||||
raw XML/agent blocks
|
||||
attachments until media policy is implemented
|
||||
```
|
||||
|
||||
This must be enforced before a message is appended as `projectable`.
|
||||
|
||||
## How To Handle Teammate Messages To User
|
||||
|
||||
The user asked for this:
|
||||
|
||||
```text
|
||||
Messages from teammates to the user should appear in Telegram,
|
||||
with each teammate clearly signed.
|
||||
```
|
||||
|
||||
Recommended rule:
|
||||
|
||||
```text
|
||||
Any known team member message with to == "user" can be appended to the conversation
|
||||
only if it is linked to an active messenger route/conversation.
|
||||
```
|
||||
|
||||
Rendering:
|
||||
|
||||
```text
|
||||
[Frontend] Alice
|
||||
I found the failing test. The callback resolves before token refresh.
|
||||
```
|
||||
|
||||
```text
|
||||
[QA] Mark
|
||||
Reproduced on the latest build. Only happens after session restore.
|
||||
```
|
||||
|
||||
Why prefix instead of separate bots:
|
||||
|
||||
- One bot per team member is much harder to manage.
|
||||
- Multiple bots do not solve core routing.
|
||||
- Prefix keeps the topic readable.
|
||||
- It works across providers later.
|
||||
|
||||
Routing requirement:
|
||||
|
||||
```text
|
||||
Do not send every message to user globally.
|
||||
Send only messages whose conversationId or relay link ties them to the active messenger conversation.
|
||||
```
|
||||
|
||||
## Conversation Identity
|
||||
|
||||
Use one active user-facing conversation per team route in MVP:
|
||||
|
||||
```text
|
||||
conversationId = routeId + currentConversationSeq
|
||||
```
|
||||
|
||||
MVP can start with:
|
||||
|
||||
```text
|
||||
one open conversation per team topic
|
||||
```
|
||||
|
||||
Later:
|
||||
|
||||
```text
|
||||
multiple conversations per team topic with task/thread labels
|
||||
```
|
||||
|
||||
Why not one conversation per message:
|
||||
|
||||
- Too noisy.
|
||||
- Hard for the lead to maintain context.
|
||||
- Telegram topic already groups by team.
|
||||
|
||||
Why not only one global conversation for all teams:
|
||||
|
||||
- Reply routing becomes ambiguous.
|
||||
- User needs team-level separation.
|
||||
- Topics per team become mostly cosmetic.
|
||||
|
||||
## Backfill Policy
|
||||
|
||||
Backfill is risky because old local history may contain private/internal context.
|
||||
|
||||
Top 3 backfill options:
|
||||
|
||||
### A. No automatic backfill, send a compact connection marker
|
||||
|
||||
🎯 9 🛡️ 9 🧠 3 Approx change size: 500-1200 LOC
|
||||
|
||||
On topic creation:
|
||||
|
||||
```text
|
||||
Connected to Agent Teams.
|
||||
Team: Frontend
|
||||
New messages will appear here.
|
||||
```
|
||||
|
||||
Optional local-only UI shows older app history, but Telegram starts clean.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Use for MVP.
|
||||
```
|
||||
|
||||
### B. User-approved summary backfill
|
||||
|
||||
🎯 8 🛡️ 8 🧠 6 Approx change size: 1800-4000 LOC
|
||||
|
||||
Desktop prepares a summary:
|
||||
|
||||
```text
|
||||
Recent context:
|
||||
- Alice is debugging auth callback tests.
|
||||
- Mark is checking session restore.
|
||||
- Open question: should refresh happen before redirect?
|
||||
```
|
||||
|
||||
User explicitly approves before sending.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Good Phase 2.
|
||||
```
|
||||
|
||||
### C. Raw transcript backfill
|
||||
|
||||
🎯 4 🛡️ 3 🧠 5 Approx change size: 1600-3600 LOC
|
||||
|
||||
Desktop sends last N messages from local feed into Telegram.
|
||||
|
||||
Problems:
|
||||
|
||||
- High privacy leak risk.
|
||||
- Rate-limit/noise risk.
|
||||
- Duplicates provider projection.
|
||||
- Old messages may lack clean source/route links.
|
||||
- Telegram message timestamps become send time, not original time.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Reject by default.
|
||||
Only allow export/manual paste workflows later.
|
||||
```
|
||||
|
||||
## History Display In Telegram
|
||||
|
||||
Telegram topic should show:
|
||||
|
||||
```text
|
||||
inbound user message
|
||||
team reply with member prefix
|
||||
short status markers
|
||||
optional typing/draft/progress indicator
|
||||
```
|
||||
|
||||
It should not try to reproduce the full local app timeline.
|
||||
|
||||
Recommended topic message examples:
|
||||
|
||||
```text
|
||||
You
|
||||
Can you check why login redirects loop?
|
||||
```
|
||||
|
||||
```text
|
||||
[Lead] Agent Teams
|
||||
I routed this to Frontend. Alice is checking the auth callback.
|
||||
```
|
||||
|
||||
```text
|
||||
[Frontend] Alice
|
||||
Found the loop. The callback reads a stale refresh token after restore.
|
||||
```
|
||||
|
||||
```text
|
||||
[System]
|
||||
Desktop went offline. Open Agent Teams and resend if this still matters.
|
||||
```
|
||||
|
||||
Avoid:
|
||||
|
||||
```text
|
||||
tool call summaries
|
||||
stdout chunks
|
||||
agent chain-of-thought style text
|
||||
raw task board mutations
|
||||
every idle/status heartbeat
|
||||
```
|
||||
|
||||
## Progress Indicators
|
||||
|
||||
Top 3 options:
|
||||
|
||||
### 1. `sendChatAction(typing)` heartbeat while a route-linked answer is pending
|
||||
|
||||
🎯 8 🛡️ 8 🧠 4 Approx change size: 700-1500 LOC
|
||||
|
||||
Pros:
|
||||
|
||||
- Official method.
|
||||
- Supports `message_thread_id`.
|
||||
- Lasts 5 seconds or less, so it naturally expires.
|
||||
- Does not create message history clutter.
|
||||
|
||||
Cons:
|
||||
|
||||
- Needs throttling.
|
||||
- Can imply active work even if the lead is blocked.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Use carefully after inbound commit, while local delivery is pending or agent turn is active.
|
||||
```
|
||||
|
||||
### 2. `sendMessageDraft`
|
||||
|
||||
🎯 6 🛡️ 6 🧠 7 Approx change size: 1200-3000 LOC
|
||||
|
||||
Pros:
|
||||
|
||||
- New Bot API method for partial generated messages.
|
||||
- Supports `message_thread_id`.
|
||||
- Could feel impressive.
|
||||
|
||||
Cons:
|
||||
|
||||
- Draft lifecycle/id semantics need real-world testing.
|
||||
- It might leak partial agent output before safety/projection filtering.
|
||||
- Harder to reconcile if final answer is suppressed.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Do not use in MVP.
|
||||
Only consider for final-answer generation after projection gate is mature.
|
||||
```
|
||||
|
||||
### 3. Explicit status messages like "Alice is working"
|
||||
|
||||
🎯 7 🛡️ 6 🧠 3 Approx change size: 500-1200 LOC
|
||||
|
||||
Pros:
|
||||
|
||||
- Simple.
|
||||
- Durable and visible.
|
||||
|
||||
Cons:
|
||||
|
||||
- Adds clutter.
|
||||
- Can become spammy.
|
||||
- Hard to keep accurate.
|
||||
|
||||
Verdict:
|
||||
|
||||
```text
|
||||
Use only for major state changes, not continuous progress.
|
||||
```
|
||||
|
||||
## Reply-To Routing
|
||||
|
||||
Incoming Telegram reply should route by priority:
|
||||
|
||||
```text
|
||||
1. reply_to_message.message_id maps to ProviderMessageLink
|
||||
2. message_thread_id maps to team route
|
||||
3. slash command selects member or action
|
||||
4. fallback to lead
|
||||
```
|
||||
|
||||
Provider message link:
|
||||
|
||||
```ts
|
||||
interface ProviderMessageLink {
|
||||
provider: 'telegram';
|
||||
routeId: string;
|
||||
providerChatIdHash: string;
|
||||
providerMessageThreadId: string;
|
||||
providerMessageId: string;
|
||||
conversationMessageId: string;
|
||||
authorKind: 'external_user' | 'team_member' | 'team_lead' | 'system';
|
||||
authorMemberId?: string;
|
||||
sentAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
User replies to Alice message
|
||||
-> route to team topic
|
||||
-> include reply target "Alice" in lead/team prompt
|
||||
-> if direct teammate reply mode is enabled, deliver to Alice inbox
|
||||
```
|
||||
|
||||
```text
|
||||
User sends a new message in team topic without reply
|
||||
-> route to lead by default
|
||||
```
|
||||
|
||||
MVP decision:
|
||||
|
||||
```text
|
||||
Do not DM arbitrary teammate automatically from reply-to.
|
||||
Route to lead with reply context first.
|
||||
```
|
||||
|
||||
Why:
|
||||
|
||||
- Lead can coordinate.
|
||||
- Teammate may be offline or mid-turn.
|
||||
- Direct teammate routing can be added after route policy is proven.
|
||||
|
||||
## Commands In Topic
|
||||
|
||||
Keep commands minimal in MVP:
|
||||
|
||||
```text
|
||||
/teams
|
||||
/status
|
||||
/help
|
||||
/disconnect
|
||||
```
|
||||
|
||||
Do not overload the topic with rich command grammar early.
|
||||
|
||||
Team selection:
|
||||
|
||||
```text
|
||||
Primary selection is topic.
|
||||
Commands are fallback and diagnostics.
|
||||
```
|
||||
|
||||
If message arrives outside a topic:
|
||||
|
||||
```text
|
||||
show active teams
|
||||
ask user to pick a topic
|
||||
do not infer from recent activity unless exactly one team is active
|
||||
```
|
||||
|
||||
## Projection State Machine
|
||||
|
||||
```text
|
||||
local_message_seen
|
||||
-> policy_checked
|
||||
-> conversation_appended
|
||||
-> projection_pending
|
||||
-> provider_sending
|
||||
-> provider_sent
|
||||
-> linked
|
||||
```
|
||||
|
||||
Failure states:
|
||||
|
||||
```text
|
||||
suppressed_by_policy
|
||||
requires_manual_approval
|
||||
provider_ambiguous
|
||||
provider_failed_retryable
|
||||
provider_failed_terminal
|
||||
route_disabled
|
||||
topic_needs_repair
|
||||
```
|
||||
|
||||
Important invariant:
|
||||
|
||||
```text
|
||||
Provider projection cannot start before the message is appended to MessengerConversationStore.
|
||||
```
|
||||
|
||||
This ensures Telegram never has a message that the local conversation store cannot explain.
|
||||
|
||||
## Duplicate Prevention
|
||||
|
||||
Use three layers:
|
||||
|
||||
```text
|
||||
1. Conversation idempotency key
|
||||
2. Projection payload hash
|
||||
3. Provider message link
|
||||
```
|
||||
|
||||
Conversation idempotency:
|
||||
|
||||
```text
|
||||
source.kind + source.localMessageId/providerUpdateId + routeId
|
||||
```
|
||||
|
||||
Projection idempotency:
|
||||
|
||||
```text
|
||||
conversationMessageId + provider + routeId + payloadHash
|
||||
```
|
||||
|
||||
Provider link:
|
||||
|
||||
```text
|
||||
stored only after sendMessage returns Message
|
||||
```
|
||||
|
||||
If Telegram send times out:
|
||||
|
||||
```text
|
||||
mark projection ambiguous
|
||||
do not retry automatically with the same text unless policy accepts duplicate risk
|
||||
surface "delivery uncertain" in local UI
|
||||
```
|
||||
|
||||
This matches earlier outbound delivery research.
|
||||
|
||||
## Edit And Delete Policy
|
||||
|
||||
Do not use Telegram edit/delete as the normal sync mechanism.
|
||||
|
||||
Reasons:
|
||||
|
||||
- `deleteMessage` has a 48-hour limit and service-message exceptions.
|
||||
- `editMessageText` has constraints and can return different shapes.
|
||||
- Edits are provider-specific and hard to reconcile across adapters.
|
||||
|
||||
MVP:
|
||||
|
||||
```text
|
||||
append-only Telegram topic
|
||||
append-only local conversation ledger
|
||||
corrections are new messages
|
||||
```
|
||||
|
||||
Later:
|
||||
|
||||
```text
|
||||
support explicit "correct last bot message" for bot-authored messages only
|
||||
```
|
||||
|
||||
## Storage And Retention
|
||||
|
||||
Do not rely on:
|
||||
|
||||
```text
|
||||
sentMessages.json cap of 200
|
||||
inboxes/user.json as long-term canonical external history
|
||||
Telegram topic as recoverable history
|
||||
```
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
getAppDataPath()/messenger-conversations/
|
||||
bindings/
|
||||
routes/
|
||||
conversations/
|
||||
projections/
|
||||
```
|
||||
|
||||
Retention tiers:
|
||||
|
||||
```text
|
||||
MVP:
|
||||
keep text conversation rows locally until user deletes route/binding
|
||||
|
||||
Later:
|
||||
per-route retention setting
|
||||
export/delete controls
|
||||
encrypted local store option
|
||||
encrypted backend queue option
|
||||
```
|
||||
|
||||
## UI Implications
|
||||
|
||||
Desktop should show:
|
||||
|
||||
```text
|
||||
Connected Telegram account
|
||||
team topics/routes
|
||||
last projected message status
|
||||
delivery uncertain warnings
|
||||
local-only vs sent-to-Telegram marker
|
||||
reconnect/repair action
|
||||
```
|
||||
|
||||
Message row badges:
|
||||
|
||||
```text
|
||||
local only
|
||||
sent to Telegram
|
||||
delivery uncertain
|
||||
blocked by policy
|
||||
needs approval
|
||||
```
|
||||
|
||||
This matters because the local app feed and Telegram topic will not always match exactly by design.
|
||||
|
||||
## Clean Architecture Placement
|
||||
|
||||
Core/domain:
|
||||
|
||||
```text
|
||||
ConversationMessage
|
||||
ConversationPolicy
|
||||
ProjectionEligibility
|
||||
ProviderMessageLink
|
||||
ProjectionStateMachine
|
||||
BackfillPolicy
|
||||
```
|
||||
|
||||
Core/application:
|
||||
|
||||
```text
|
||||
AppendInboundProviderMessageUseCase
|
||||
AppendLocalReplyUseCase
|
||||
EvaluateProjectionUseCase
|
||||
ProjectConversationMessageUseCase
|
||||
ReconcileProjectionUseCase
|
||||
BuildBackfillPreviewUseCase
|
||||
```
|
||||
|
||||
Ports:
|
||||
|
||||
```text
|
||||
MessengerConversationStore
|
||||
MessengerProjectionLedger
|
||||
MessengerProviderGateway
|
||||
TeamMessageSource
|
||||
ExternalVisibilityPolicy
|
||||
```
|
||||
|
||||
Adapters:
|
||||
|
||||
```text
|
||||
TeamMessageFeedInputAdapter
|
||||
TelegramProjectionAdapter
|
||||
FileConversationStore
|
||||
FileProjectionLedger
|
||||
```
|
||||
|
||||
Important dependency rule:
|
||||
|
||||
```text
|
||||
TeamMessageFeedInputAdapter may depend on existing team services.
|
||||
Core policy must not depend on TeamMessageFeedService.
|
||||
```
|
||||
|
||||
## Edge Cases To Test
|
||||
|
||||
History and projection:
|
||||
|
||||
- Topic created after team already has a long local message history.
|
||||
- No automatic raw backfill occurs.
|
||||
- User-approved summary backfill sends only approved summary.
|
||||
- `sentMessages.json` drops old rows, but MessengerConversationStore keeps route conversation history.
|
||||
- Same local message appears in both inbox and sent messages, only one conversation row is created.
|
||||
- Same conversation row is not projected twice.
|
||||
|
||||
Teammate messages:
|
||||
|
||||
- Alice sends `to=user` in a route-linked conversation, Telegram gets `[Alice]`.
|
||||
- Alice sends `to=user` outside a route-linked conversation, Telegram gets nothing.
|
||||
- Alice sends teammate-internal message, Telegram gets nothing.
|
||||
- Lead sends generic thought with no `to=user`, Telegram gets nothing.
|
||||
- Slash command result is visible in UI, Telegram gets nothing by default.
|
||||
|
||||
Reply routing:
|
||||
|
||||
- User replies to Alice's Telegram message, provider link maps to Alice context.
|
||||
- User replies to system offline notice, route remains lead fallback.
|
||||
- User writes in topic without reply, route goes to lead.
|
||||
- User writes outside topic with multiple teams connected, bot asks to choose topic.
|
||||
- Unknown provider message id does not crash routing.
|
||||
|
||||
Provider behavior:
|
||||
|
||||
- `sendMessage` success stores provider message link.
|
||||
- `sendMessage` timeout marks ambiguous and does not auto-duplicate.
|
||||
- `deleteMessage` failure does not corrupt local conversation.
|
||||
- `editForumTopic` failure does not reroute by title.
|
||||
- Topic repair creates new topic and marks old projection state historical.
|
||||
|
||||
Privacy:
|
||||
|
||||
- Internal blocks stripped before projectable rows.
|
||||
- Policy blocks `permission_request` JSON.
|
||||
- Policy blocks tool stdout/stderr unless manually approved.
|
||||
- Backfill preview redacts secrets and requires explicit approval.
|
||||
|
||||
## Decision Update
|
||||
|
||||
Add this to the implementation plan:
|
||||
|
||||
```text
|
||||
MessengerConversationStore is mandatory for MVP.
|
||||
Telegram topic is provider projection only.
|
||||
No raw automatic history backfill.
|
||||
One topic per team route.
|
||||
One open conversation per team topic in MVP.
|
||||
Teammate messages to user are projected only when route-linked and external-safe.
|
||||
```
|
||||
|
||||
Recommended MVP behavior:
|
||||
|
||||
```text
|
||||
Connect Telegram
|
||||
-> create one topic per selected team
|
||||
-> send a short connection marker
|
||||
-> start projecting new inbound/outbound external-safe messages
|
||||
-> show local projection status in desktop
|
||||
```
|
||||
|
||||
Main remaining uncertainty:
|
||||
|
||||
```text
|
||||
Should reply-to a teammate message route directly to that teammate,
|
||||
or always go through lead with reply context?
|
||||
```
|
||||
|
||||
My current recommendation:
|
||||
|
||||
🎯 8 🛡️ 8 🧠 5 Approx change size: +800-1800 LOC
|
||||
|
||||
```text
|
||||
MVP routes all Telegram inbound through lead,
|
||||
but includes reply-to teammate context in the prompt.
|
||||
Add direct teammate routing later as an explicit per-team setting.
|
||||
```
|
||||
|
||||
Reason:
|
||||
|
||||
```text
|
||||
It preserves coordination, avoids surprising teammate interruptions,
|
||||
and still lets the lead tell Alice "the user replied to your message".
|
||||
```
|
||||
|
|
@ -12,41 +12,48 @@
|
|||
|
||||
## Документация
|
||||
|
||||
| Файл | Содержание |
|
||||
|------|-----------|
|
||||
| [research-inbox.md](./research-inbox.md) | Формат inbox-файлов, race conditions, atomic write, доставка сообщений |
|
||||
| [research-tasks.md](./research-tasks.md) | Формат task-файлов, .lock, .highwatermark, конкурентный доступ |
|
||||
| [research-messaging.md](./research-messaging.md) | Сравнение подходов (inbox vs SDK vs CLI), почему выбрали inbox |
|
||||
| [kanban-design.md](./kanban-design.md) | Kanban flow, колонки, review mechanism, kanban-state.json |
|
||||
| [implementation.md](./implementation.md) | Техплан: файлы, шаги, verification |
|
||||
| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) |
|
||||
| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync |
|
||||
| Файл | Содержание |
|
||||
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| [research-inbox.md](./research-inbox.md) | Формат inbox-файлов, race conditions, atomic write, доставка сообщений |
|
||||
| [research-tasks.md](./research-tasks.md) | Формат task-файлов, .lock, .highwatermark, конкурентный доступ |
|
||||
| [research-messaging.md](./research-messaging.md) | Сравнение подходов (inbox vs SDK vs CLI), почему выбрали inbox |
|
||||
| [kanban-design.md](./kanban-design.md) | Kanban flow, колонки, review mechanism, kanban-state.json |
|
||||
| [implementation.md](./implementation.md) | Техплан: файлы, шаги, verification |
|
||||
| [openclaw-agent-teams-integration.md](./openclaw-agent-teams-integration.md) | How to connect OpenClaw or another outside AI through Agent Teams MCP and REST control API |
|
||||
| [research-worktrees.md](./research-worktrees.md) | Git worktrees + teams, запуск Claude процессов из UI (Phase 2) |
|
||||
| [task-queue-derived-agenda-plan.md](./task-queue-derived-agenda-plan.md) | Подробный rollout-plan по разделению queue/inventory, derived actionOwner и phased agenda/delta sync |
|
||||
|
||||
## Ключевые решения
|
||||
|
||||
⚠️ `docs/iterations/*` - это исторические planning notes. Они полезны для контекста, но не являются source-of-truth для текущего поведения продукта. Актуальный контракт review flow описан в этом файле и в [kanban-design.md](./kanban-design.md).
|
||||
|
||||
### 1. Messaging: Inbox-файлы
|
||||
|
||||
Единственный способ общаться с **запущенными** тиммейтами. SDK и CLI создают новые сессии, а не подключаются к существующим. Подробности: [research-messaging.md](./research-messaging.md)
|
||||
|
||||
### 1.1 Roster source: members.meta.json + inboxes
|
||||
|
||||
- `config.json` не используется как полный реестр участников (он может содержать только team-lead и служебные поля CLI).
|
||||
- Источник метаданных участников (role/color/agentType): `members.meta.json`.
|
||||
- Источник runtime-состава и адресации сообщений: `inboxes/{member}.json`.
|
||||
|
||||
### 2. Kanban Storage: Собственный файл
|
||||
|
||||
Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`, а не в task metadata. Причина: metadata может быть перезаписан агентом при TaskUpdate. Подробности: [kanban-design.md](./kanban-design.md)
|
||||
|
||||
### 3. Review Flow: Approve / Request Changes
|
||||
|
||||
- Есть ревьюверы в команде → автоматическое назначение через inbox
|
||||
- Юзер также может вручную одобрить задачу напрямую из `DONE` без отдельного захода в `REVIEW`
|
||||
- Нет ревьюверов → ручное ревью юзером (Approve / Request Changes в UI)
|
||||
- При Request Changes → юзер описывает проблему (опционально) → задача возвращается owner'у в `pending` с `needsFix`
|
||||
|
||||
### 4. Atomic Write
|
||||
|
||||
Все записи через tmp + rename для предотвращения corrupted JSON.
|
||||
|
||||
### 5. Sender Identity
|
||||
|
||||
Отправляем `from: "user"`. Fallback на `from: "team-lead"` если не работает.
|
||||
|
||||
## Финальные решения после ревью
|
||||
|
|
@ -54,16 +61,19 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
|
|||
По итогам 3 раундов ревью (13 экспертов) приняты следующие решения:
|
||||
|
||||
### Inbox: Atomic write + messageId verify
|
||||
|
||||
- Atomic write (tmp + rename) предотвращает corrupted JSON
|
||||
- После записи читаем файл обратно и проверяем наличие нашего `messageId`
|
||||
- Полный CAS/retry-цикл — не нужен на MVP: проверка при следующем read достаточна
|
||||
- Риск race condition с агентом реален, но вероятность низкая
|
||||
|
||||
### Kanban: kanban-state.json с безопасным GC
|
||||
|
||||
- GC устаревших записей kanban-state выполняется ТОЛЬКО ПОСЛЕ полной загрузки tasks
|
||||
- Иначе при startup возможна race condition: GC удаляет запись до того как task-файл прочитан
|
||||
|
||||
### Review Flow: Approve / Request Changes
|
||||
|
||||
- Кнопки переименованы: **Approve** (вместо OK) и **Request Changes** (вместо Error)
|
||||
- Комментарий при Request Changes — опционален
|
||||
- Manual UI допускает два valid path:
|
||||
|
|
@ -73,10 +83,12 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
|
|||
- `reviewHistory` и round-robin балансировка → Phase 2, не MVP
|
||||
|
||||
### Members: полный список через union
|
||||
|
||||
- `union(config members + inbox filenames + task owners)` — единственный способ получить полный список
|
||||
- `owner` в task-файлах — опционален (агент может не иметь owner до назначения)
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
- `try/catch` везде в TeamDataService — при ошибке чтения возвращаем безопасные дефолты
|
||||
- 3 состояния участника: `ACTIVE` / `IDLE` / `TERMINATED`
|
||||
- `ACTIVE`: idle < 5 минут
|
||||
|
|
@ -84,6 +96,7 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
|
|||
- `TERMINATED`: получен `shutdown_response` с `approve: true`
|
||||
|
||||
### @dnd-kit and review transitions
|
||||
|
||||
- Переходы между review-колонками делаются через card actions в UI
|
||||
- `@dnd-kit` сейчас используется в первую очередь для перестановки задач внутри колонки
|
||||
- Phase 2: полноценный D&D через `@dnd-kit`
|
||||
|
|
@ -113,5 +126,6 @@ Kanban-позиция (REVIEW, APPROVED) хранится в `kanban-state.json`
|
|||
```
|
||||
|
||||
**ВАЖНО**:
|
||||
|
||||
- `config.json` не является source-of-truth для полного roster.
|
||||
- Полный roster для UI формируется как `members.meta.json + inbox filenames (+ lead из config)`.
|
||||
|
|
|
|||
3601
docs/team-management/member-work-sync-control-plane-plan.md
Normal file
3601
docs/team-management/member-work-sync-control-plane-plan.md
Normal file
File diff suppressed because it is too large
Load diff
1212
docs/team-management/openclaw-agent-teams-integration.md
Normal file
1212
docs/team-management/openclaw-agent-teams-integration.md
Normal file
File diff suppressed because it is too large
Load diff
18
mcp-server/src/agent-teams-controller.d.ts
vendored
18
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -8,7 +8,20 @@ declare module 'agent-teams-controller' {
|
|||
export interface ControllerTaskApi {
|
||||
createTask(flags: Record<string, unknown>): unknown;
|
||||
getTask(taskId: string): unknown;
|
||||
getTaskComment(taskId: string, commentId: string): { comment: Record<string, unknown>; task: { id: string; displayId: string; subject: string; status: string; owner: string | null; commentCount: number } };
|
||||
getTaskComment(
|
||||
taskId: string,
|
||||
commentId: string
|
||||
): {
|
||||
comment: Record<string, unknown>;
|
||||
task: {
|
||||
id: string;
|
||||
displayId: string;
|
||||
subject: string;
|
||||
status: string;
|
||||
owner: string | null;
|
||||
commentCount: number;
|
||||
};
|
||||
};
|
||||
listTasks(): unknown[];
|
||||
listTaskInventory(filters?: Record<string, unknown>): unknown[];
|
||||
listDeletedTasks(): unknown[];
|
||||
|
|
@ -77,6 +90,9 @@ declare module 'agent-teams-controller' {
|
|||
}
|
||||
|
||||
export interface ControllerRuntimeApi {
|
||||
listTeams(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
getTeam(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
createTeam(flags: Record<string, unknown>): Promise<unknown>;
|
||||
launchTeam(flags: Record<string, unknown>): Promise<unknown>;
|
||||
stopTeam(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
getRuntimeState(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
|
|
|
|||
|
|
@ -13,8 +13,10 @@ import { registerProcessTools } from './processTools';
|
|||
import { registerReviewTools } from './reviewTools';
|
||||
import { registerRuntimeTools } from './runtimeTools';
|
||||
import { registerTaskTools } from './taskTools';
|
||||
import { registerTeamTools } from './teamTools';
|
||||
|
||||
const REGISTRATION_BY_GROUP = {
|
||||
team: registerTeamTools,
|
||||
task: registerTaskTools,
|
||||
lead: registerLeadTools,
|
||||
kanban: registerKanbanTools,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
import { assertConfiguredOrDraftTeam, assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -59,7 +59,7 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
extraCliArgs,
|
||||
waitForReady,
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
assertConfiguredOrDraftTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.launchTeam({
|
||||
cwd,
|
||||
|
|
@ -99,7 +99,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
server.addTool({
|
||||
name: 'runtime_bootstrap_checkin',
|
||||
description: 'Confirm that an OpenCode team member runtime reached the app MCP bootstrap boundary',
|
||||
description:
|
||||
'Confirm that an OpenCode team member runtime reached the app MCP bootstrap boundary',
|
||||
parameters: z.object({
|
||||
...runtimeIdentitySchema,
|
||||
observedAt: z.string().min(1).optional(),
|
||||
|
|
|
|||
140
mcp-server/src/tools/teamTools.ts
Normal file
140
mcp-server/src/tools/teamTools.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
|
||||
const controlContextSchema = {
|
||||
claudeDir: z.string().min(1).optional(),
|
||||
controlUrl: z.string().optional(),
|
||||
waitTimeoutMs: z.number().int().min(1000).max(600000).optional(),
|
||||
};
|
||||
|
||||
const teamContextSchema = {
|
||||
...controlContextSchema,
|
||||
teamName: z.string().min(1),
|
||||
};
|
||||
|
||||
const providerIdSchema = z.enum(['anthropic', 'codex', 'gemini', 'opencode']);
|
||||
const effortSchema = z.enum(['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
||||
const fastModeSchema = z.enum(['inherit', 'on', 'off']);
|
||||
|
||||
const memberSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
role: z.string().optional(),
|
||||
workflow: z.string().optional(),
|
||||
isolation: z.literal('worktree').optional(),
|
||||
providerId: providerIdSchema.optional(),
|
||||
providerBackendId: z.string().min(1).optional(),
|
||||
model: z.string().min(1).optional(),
|
||||
effort: effortSchema.optional(),
|
||||
fastMode: fastModeSchema.optional(),
|
||||
});
|
||||
|
||||
function controlFlags(args: {
|
||||
controlUrl?: string;
|
||||
waitTimeoutMs?: number;
|
||||
}): Record<string, unknown> {
|
||||
return {
|
||||
...(args.controlUrl ? { controlUrl: args.controlUrl } : {}),
|
||||
...(args.waitTimeoutMs ? { waitTimeoutMs: args.waitTimeoutMs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function registerTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'team_list',
|
||||
description: 'List teams through the local Agent Teams control API',
|
||||
parameters: z.object({
|
||||
...controlContextSchema,
|
||||
}),
|
||||
execute: async ({ claudeDir, controlUrl, waitTimeoutMs }) => {
|
||||
return jsonTextContent(
|
||||
await getController('agent-teams-control', claudeDir).runtime.listTeams(
|
||||
controlFlags({ controlUrl, waitTimeoutMs })
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'team_get',
|
||||
description: 'Get a team snapshot through the local Agent Teams control API',
|
||||
parameters: z.object({
|
||||
...teamContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs }) => {
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.getTeam(
|
||||
controlFlags({ controlUrl, waitTimeoutMs })
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'team_create',
|
||||
description:
|
||||
'Create a draft team configuration through the local Agent Teams control API. This does not launch the team.',
|
||||
parameters: z.object({
|
||||
...teamContextSchema,
|
||||
displayName: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
color: z.string().min(1).optional(),
|
||||
members: z.array(memberSchema).optional(),
|
||||
cwd: z.string().min(1).optional(),
|
||||
prompt: z.string().min(1).optional(),
|
||||
providerId: providerIdSchema.optional(),
|
||||
providerBackendId: z.string().min(1).optional(),
|
||||
model: z.string().min(1).optional(),
|
||||
effort: effortSchema.optional(),
|
||||
fastMode: fastModeSchema.optional(),
|
||||
limitContext: z.boolean().optional(),
|
||||
skipPermissions: z.boolean().optional(),
|
||||
worktree: z.string().min(1).optional(),
|
||||
extraCliArgs: z.string().min(1).optional(),
|
||||
}),
|
||||
execute: async ({
|
||||
teamName,
|
||||
claudeDir,
|
||||
controlUrl,
|
||||
waitTimeoutMs,
|
||||
displayName,
|
||||
description,
|
||||
color,
|
||||
members,
|
||||
cwd,
|
||||
prompt,
|
||||
providerId,
|
||||
providerBackendId,
|
||||
model,
|
||||
effort,
|
||||
fastMode,
|
||||
limitContext,
|
||||
skipPermissions,
|
||||
worktree,
|
||||
extraCliArgs,
|
||||
}) => {
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.createTeam({
|
||||
...controlFlags({ controlUrl, waitTimeoutMs }),
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(members ? { members } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(prompt ? { prompt } : {}),
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(providerBackendId ? { providerBackendId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(fastMode ? { fastMode } : {}),
|
||||
...(limitContext !== undefined ? { limitContext } : {}),
|
||||
...(skipPermissions !== undefined ? { skipPermissions } : {}),
|
||||
...(worktree ? { worktree } : {}),
|
||||
...(extraCliArgs ? { extraCliArgs } : {}),
|
||||
})
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -3,38 +3,73 @@ import path from 'node:path';
|
|||
|
||||
import { getController } from '../controller';
|
||||
|
||||
function resolveConfigPath(teamName: string, claudeDir?: string): string {
|
||||
function unknownTeamMessage(teamName: string): string {
|
||||
return `Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`;
|
||||
}
|
||||
|
||||
function resolveTeamPaths(
|
||||
teamName: string,
|
||||
claudeDir?: string
|
||||
): {
|
||||
configPath: string;
|
||||
metaPath: string;
|
||||
} {
|
||||
const controller = getController(teamName, claudeDir) as {
|
||||
context?: { paths?: { teamDir?: string } };
|
||||
};
|
||||
const teamDir = controller.context?.paths?.teamDir;
|
||||
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
throw new Error(unknownTeamMessage(teamName));
|
||||
}
|
||||
return path.join(teamDir, 'config.json');
|
||||
return {
|
||||
configPath: path.join(teamDir, 'config.json'),
|
||||
metaPath: path.join(teamDir, 'team.meta.json'),
|
||||
};
|
||||
}
|
||||
|
||||
function readJsonObject(filePath: string): Record<string, unknown> | null {
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(filePath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isConfiguredTeamConfig(value: Record<string, unknown> | null): boolean {
|
||||
return typeof value?.name === 'string' && value.name.trim().length > 0;
|
||||
}
|
||||
|
||||
function isDraftTeamMeta(value: Record<string, unknown> | null): boolean {
|
||||
return value?.version === 1 && typeof value.cwd === 'string' && value.cwd.trim().length > 0;
|
||||
}
|
||||
|
||||
export function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
|
||||
const configPath = resolveConfigPath(teamName, claudeDir);
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
const { configPath } = resolveTeamPaths(teamName, claudeDir);
|
||||
const parsed = readJsonObject(configPath);
|
||||
if (!isConfiguredTeamConfig(parsed)) {
|
||||
throw new Error(unknownTeamMessage(teamName));
|
||||
}
|
||||
}
|
||||
|
||||
export function assertConfiguredOrDraftTeam(teamName: string, claudeDir?: string): void {
|
||||
const { configPath, metaPath } = resolveTeamPaths(teamName, claudeDir);
|
||||
if (isConfiguredTeamConfig(readJsonObject(configPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDraftTeamMeta(readJsonObject(metaPath))) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(unknownTeamMessage(teamName));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -224,6 +224,108 @@ describe('agent-teams-mcp tools', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('lists, gets, and creates teams through the local control API', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
|
||||
if (method === 'GET' && url === '/api/teams') {
|
||||
return {
|
||||
body: [
|
||||
{
|
||||
teamName: 'alpha',
|
||||
displayName: 'Alpha',
|
||||
description: '',
|
||||
memberCount: 1,
|
||||
taskCount: 0,
|
||||
lastActivity: null,
|
||||
pendingCreate: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (method === 'GET' && url === '/api/teams/alpha') {
|
||||
return {
|
||||
body: {
|
||||
teamName: 'alpha',
|
||||
members: [{ name: 'builder', role: 'Engineer' }],
|
||||
tasks: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
if (method === 'POST' && url === '/api/teams') {
|
||||
return { statusCode: 201, body: { teamName: 'alpha' } };
|
||||
}
|
||||
|
||||
return { statusCode: 404, body: { error: `Unhandled ${method} ${url}` } };
|
||||
});
|
||||
|
||||
try {
|
||||
const listed = parseJsonToolResult(
|
||||
await getTool('team_list').execute({
|
||||
claudeDir,
|
||||
controlUrl: server.baseUrl,
|
||||
})
|
||||
);
|
||||
expect(listed[0].teamName).toBe('alpha');
|
||||
|
||||
const fetched = parseJsonToolResult(
|
||||
await getTool('team_get').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
})
|
||||
);
|
||||
expect(fetched.teamName).toBe('alpha');
|
||||
|
||||
const created = parseJsonToolResult(
|
||||
await getTool('team_create').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
displayName: 'Alpha',
|
||||
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
})
|
||||
);
|
||||
expect(created.teamName).toBe('alpha');
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/api/teams',
|
||||
body: undefined,
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/api/teams/alpha',
|
||||
body: undefined,
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
url: '/api/teams',
|
||||
body: {
|
||||
teamName: 'alpha',
|
||||
displayName: 'Alpha',
|
||||
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
|
|
@ -1317,7 +1419,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
).rejects.toThrow('Unknown team "typo-team"');
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'typo-team'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'real-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'real-team', 'inboxes', 'lead.json'))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('exposes zod schemas that reject obviously invalid payloads', () => {
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.12",
|
||||
"sourceRef": "v0.0.12",
|
||||
"version": "0.0.13",
|
||||
"sourceRef": "v0.0.13",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/claude_agent_teams_ui",
|
||||
"releaseTag": "v1.2.0",
|
||||
"assets": {
|
||||
"darwin-arm64": {
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.12.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.13.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"darwin-x64": {
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.12.tar.gz",
|
||||
"file": "agent-teams-runtime-darwin-x64-v0.0.13.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"linux-x64": {
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.12.tar.gz",
|
||||
"file": "agent-teams-runtime-linux-x64-v0.0.13.tar.gz",
|
||||
"archiveKind": "tar.gz",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.12.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.13.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ function buildManualHints(platform: TmuxPlatform): TmuxInstallHint[] {
|
|||
},
|
||||
{
|
||||
title: 'Install Ubuntu',
|
||||
description: 'Recommended WSL distro for the tmux runtime path.',
|
||||
description: 'Recommended WSL distro for optional tmux pane transport.',
|
||||
command: 'wsl --install -d Ubuntu --no-launch',
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function buildTmuxEffectiveAvailability(
|
|||
binaryPath: input.wsl.tmuxBinaryPath,
|
||||
runtimeReady: false,
|
||||
detail:
|
||||
'tmux is available inside WSL, but the persistent teammate runtime still needs native Windows pane support.',
|
||||
'tmux is available inside WSL. On Windows it is optional and is not required for teammate runtime startup.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ export function buildTmuxEffectiveAvailability(
|
|||
binaryPath: input.host.binaryPath,
|
||||
runtimeReady: false,
|
||||
detail:
|
||||
'tmux was found on Windows, but the app currently relies on a WSL-backed tmux runtime for the most reliable teammate path.',
|
||||
'tmux was found on Windows. Native process teammates do not require it; tmux remains optional for pane-based terminal transport.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ export function buildTmuxEffectiveAvailability(
|
|||
runtimeReady: false,
|
||||
detail:
|
||||
input.wsl?.statusDetail ??
|
||||
'You can keep using the app, but Windows needs WSL before tmux can improve teammate reliability.',
|
||||
'You can keep using the app without tmux. Install WSL only if you want optional tmux pane transport.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +61,7 @@ export function buildTmuxEffectiveAvailability(
|
|||
runtimeReady: false,
|
||||
detail:
|
||||
input.wsl?.statusDetail ??
|
||||
'WSL is available, but tmux is not ready there yet. Finish the Linux setup, install tmux, then re-check.',
|
||||
'WSL is available, but tmux is not ready there yet. Install tmux only if you want optional pane transport.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ export function buildTmuxEffectiveAvailability(
|
|||
version: input.host.version,
|
||||
binaryPath: input.host.binaryPath,
|
||||
runtimeReady: input.nativeSupported,
|
||||
detail: 'tmux is available for the persistent teammate runtime.',
|
||||
detail: 'tmux is available as an optional pane transport for teammate sessions.',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -84,9 +84,9 @@ export function buildTmuxEffectiveAvailability(
|
|||
runtimeReady: false,
|
||||
detail:
|
||||
input.platform === 'darwin'
|
||||
? 'You can keep using the app, but tmux improves persistent teammate reliability and restart behavior.'
|
||||
? 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.'
|
||||
: input.platform === 'linux'
|
||||
? 'You can keep using the app, but tmux improves long-running teammate stability and cleaner recovery.'
|
||||
: 'You can keep using the app, but tmux improves persistent teammate reliability.',
|
||||
? 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.'
|
||||
: 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ export class TmuxInstallerRunnerAdapter
|
|||
strategy: 'wsl',
|
||||
message: 'Preparing the Windows WSL tmux setup...',
|
||||
detail:
|
||||
'The app can keep working without tmux, but WSL-backed tmux gives the most reliable persistent teammate path on Windows.',
|
||||
'The app can keep working without tmux. WSL-backed tmux is optional and only adds pane-based terminal transport on Windows.',
|
||||
error: null,
|
||||
canCancel: true,
|
||||
acceptsInput: false,
|
||||
|
|
|
|||
|
|
@ -171,22 +171,22 @@ export class TmuxInstallStrategyResolver {
|
|||
if (input.effective.available) {
|
||||
return input.effective.location === 'wsl'
|
||||
? 'tmux is available inside WSL on Windows.'
|
||||
: 'tmux is available for persistent teammate runtime.';
|
||||
: 'tmux is available as an optional pane transport for teammate sessions.';
|
||||
}
|
||||
|
||||
if (input.platform === 'darwin') {
|
||||
return 'You can keep using the app, but tmux improves persistent teammate reliability and restart behavior.';
|
||||
return 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.';
|
||||
}
|
||||
if (input.platform === 'linux') {
|
||||
return 'You can keep using the app, but tmux improves long-running teammate stability and cleaner recovery.';
|
||||
return 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.';
|
||||
}
|
||||
if (input.platform === 'win32') {
|
||||
return (
|
||||
input.wsl?.statusDetail ??
|
||||
'You can keep using the app, but tmux on Windows goes through WSL for the best teammate experience.'
|
||||
'You can keep using the app without tmux. On Windows, tmux setup uses WSL and is only needed for optional pane transport.'
|
||||
);
|
||||
}
|
||||
return 'You can keep using the app, but tmux improves persistent teammate reliability.';
|
||||
return 'You can keep using the app without tmux. Install tmux only if you want optional pane transport.';
|
||||
}
|
||||
|
||||
#buildCommand(
|
||||
|
|
@ -329,7 +329,7 @@ export class TmuxInstallStrategyResolver {
|
|||
if (status.wslInstalled && !status.distroName) {
|
||||
this.#prependUniqueHint(manualHints, {
|
||||
title: 'Install Ubuntu',
|
||||
description: 'Recommended WSL distro for the tmux runtime path.',
|
||||
description: 'Recommended WSL distro for optional tmux pane transport.',
|
||||
command: 'wsl --install -d Ubuntu --no-launch',
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,14 +83,14 @@ export class TmuxInstallerBannerAdapter {
|
|||
snapshot.message ??
|
||||
status?.effective.detail ??
|
||||
status?.wsl?.statusDetail ??
|
||||
'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.';
|
||||
'tmux is optional. Install it only if you want pane-based terminal transport for long-running teammate sessions.';
|
||||
const benefitsBody =
|
||||
status && !status.effective.available ? formatTmuxOptionalBenefits(status.platform) : null;
|
||||
const runtimeReadyLabel = status
|
||||
? status.effective.runtimeReady
|
||||
? 'Ready for persistent teammates'
|
||||
? 'Pane transport ready'
|
||||
: status.effective.available
|
||||
? 'Installed, but not active yet'
|
||||
? 'Installed, optional transport inactive'
|
||||
: null
|
||||
: null;
|
||||
const versionLabel =
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ const baseStatus: TmuxStatus = {
|
|||
version: null,
|
||||
binaryPath: null,
|
||||
runtimeReady: false,
|
||||
detail: 'tmux improves persistent teammate reliability.',
|
||||
detail: 'tmux is optional. Install it only if you want pane transport.',
|
||||
},
|
||||
error: null,
|
||||
autoInstall: {
|
||||
|
|
@ -72,9 +72,9 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
expect(result.progressPercent).toBeNull();
|
||||
expect(result.manualHints).toHaveLength(1);
|
||||
expect(result.manualHintsCollapsible).toBe(false);
|
||||
expect(result.body).toContain('persistent teammate reliability');
|
||||
expect(result.benefitsBody).toContain('Optional, but recommended');
|
||||
expect(result.benefitsBody).toContain('multi-agent teams that mix providers');
|
||||
expect(result.body).toContain('tmux is optional');
|
||||
expect(result.benefitsBody).toContain('Optional');
|
||||
expect(result.benefitsBody).toContain('pane-based terminal transport');
|
||||
expect(result.installButtonPrimary).toBe(true);
|
||||
expect(result.showRefreshButton).toBe(true);
|
||||
});
|
||||
|
|
@ -102,7 +102,7 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
|
||||
expect(result.title).toBe('Installing tmux');
|
||||
expect(result.body).toBe('Renderer bridge failed');
|
||||
expect(result.benefitsBody).toContain('Optional, but recommended');
|
||||
expect(result.benefitsBody).toContain('Optional');
|
||||
expect(result.error).toBe('Renderer bridge failed');
|
||||
expect(result.installDisabled).toBe(true);
|
||||
expect(result.canCancel).toBe(true);
|
||||
|
|
@ -171,7 +171,7 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
expect(result.primaryGuideUrl).toBe('https://learn.microsoft.com/en-us/windows/wsl/install');
|
||||
expect(result.progressPercent).toBe(82);
|
||||
expect(result.manualHintsCollapsible).toBe(true);
|
||||
expect(result.benefitsBody).toContain('With tmux in WSL');
|
||||
expect(result.benefitsBody).toContain('WSL-backed tmux');
|
||||
expect(result.showRefreshButton).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -188,7 +188,7 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
version: 'tmux 3.4',
|
||||
binaryPath: 'C:\\tmux.exe',
|
||||
runtimeReady: false,
|
||||
detail: 'tmux was found on Windows, but WSL-backed tmux is still preferred.',
|
||||
detail: 'tmux was found on Windows. Native process teammates do not require it.',
|
||||
},
|
||||
},
|
||||
snapshot: idleSnapshot,
|
||||
|
|
@ -199,7 +199,7 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
|
||||
expect(result.visible).toBe(false);
|
||||
expect(result.locationLabel).toBe('Host runtime');
|
||||
expect(result.runtimeReadyLabel).toBe('Installed, but not active yet');
|
||||
expect(result.runtimeReadyLabel).toBe('Installed, optional transport inactive');
|
||||
expect(result.versionLabel).toBe('tmux 3.4');
|
||||
expect(result.benefitsBody).toBeNull();
|
||||
});
|
||||
|
|
@ -216,7 +216,7 @@ describe('TmuxInstallerBannerAdapter', () => {
|
|||
version: 'tmux 3.6a',
|
||||
binaryPath: '/opt/homebrew/bin/tmux',
|
||||
runtimeReady: true,
|
||||
detail: 'tmux is available for persistent teammates.',
|
||||
detail: 'tmux is available as an optional pane transport.',
|
||||
},
|
||||
},
|
||||
snapshot: {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ const baseStatus: TmuxStatus = {
|
|||
version: null,
|
||||
binaryPath: null,
|
||||
runtimeReady: false,
|
||||
detail: 'tmux improves persistent teammate reliability.',
|
||||
detail: 'tmux is optional. Install it only if you want pane transport.',
|
||||
},
|
||||
error: null,
|
||||
autoInstall: {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ const baseViewModel: TmuxInstallerBannerViewModel = {
|
|||
title: 'tmux is not installed',
|
||||
body: 'WSL is available, but no Linux distribution is installed yet.',
|
||||
benefitsBody:
|
||||
'Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable. Without tmux, creating multi-agent teams that mix providers may be blocked.',
|
||||
'Optional. The app works without tmux. Install WSL-backed tmux only if you want pane-based terminal transport for long-running teammate sessions.',
|
||||
error: null,
|
||||
platformLabel: 'Windows',
|
||||
locationLabel: null,
|
||||
|
|
@ -94,8 +94,8 @@ describe('TmuxInstallerBannerView', () => {
|
|||
const { host, root } = renderBanner(baseViewModel);
|
||||
|
||||
expect(host.textContent).toContain('tmux is not installed');
|
||||
expect(host.textContent).toContain('Optional, but recommended');
|
||||
expect(host.textContent).toContain('multi-agent teams that mix providers');
|
||||
expect(host.textContent).toContain('Optional');
|
||||
expect(host.textContent).toContain('pane-based terminal transport');
|
||||
expect(host.textContent).not.toContain(
|
||||
'WSL is available, but no Linux distribution is installed yet.'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -67,12 +67,9 @@ export function formatTmuxOptionalBenefits(platform: TmuxPlatform | null): strin
|
|||
return null;
|
||||
}
|
||||
|
||||
const mixedProviderLimit =
|
||||
'Without tmux, creating multi-agent teams that mix providers may be blocked.';
|
||||
|
||||
if (platform === 'win32') {
|
||||
return `Optional, but recommended. The app works without tmux. With tmux in WSL, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better. ${mixedProviderLimit}`;
|
||||
return 'Optional. The app works without tmux. Install WSL-backed tmux only if you want pane-based terminal transport for long-running teammate sessions.';
|
||||
}
|
||||
|
||||
return `Optional, but recommended. The app works without tmux. With tmux, teammates are more reliable for long-running work, restarts are cleaner, and recovery after reconnects is better. ${mixedProviderLimit}`;
|
||||
return 'Optional. The app works without tmux. Install tmux only if you want pane-based terminal transport for long-running teammate sessions.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import type {
|
|||
UpdaterService,
|
||||
} from '../services';
|
||||
import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager';
|
||||
import type { TeamDataService } from '../services/team/TeamDataService';
|
||||
import type { TeamProvisioningService } from '../services/team/TeamProvisioningService';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ export interface HttpServices {
|
|||
recentProjectsFeature?: RecentProjectsFeatureFacade;
|
||||
updaterService: UpdaterService;
|
||||
sshConnectionManager: SshConnectionManager;
|
||||
teamDataService?: TeamDataService;
|
||||
teamProvisioningService?: TeamProvisioningService;
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +61,7 @@ export function registerHttpRoutes(
|
|||
registerSessionRoutes(app, services);
|
||||
registerSearchRoutes(app, services);
|
||||
registerSubagentRoutes(app, services);
|
||||
if (services.teamProvisioningService) {
|
||||
if (services.teamProvisioningService || services.teamDataService) {
|
||||
registerTeamRoutes(app, services);
|
||||
}
|
||||
registerNotificationRoutes(app);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { validateTeamName } from '@main/ipc/guards';
|
||||
import { validateTeammateName, validateTeamName } from '@main/ipc/guards';
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
import { extractUserFlags, PROTECTED_CLI_FLAGS } from '@shared/utils/cliArgsParser';
|
||||
import {
|
||||
formatEffortLevelListForProvider,
|
||||
isTeamEffortLevelForProvider,
|
||||
|
|
@ -7,26 +9,44 @@ import { getErrorMessage } from '@shared/utils/errorHandling';
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { isTeamProviderId } from '@shared/utils/teamProvider';
|
||||
import { isAbsolute } from 'path';
|
||||
import { constants as fsConstants } from 'fs';
|
||||
import { access } from 'fs/promises';
|
||||
import { isAbsolute, join } from 'path';
|
||||
|
||||
import type { HttpServices } from './index';
|
||||
import type { EffortLevel, TeamFastMode, TeamLaunchRequest } from '@shared/types/team';
|
||||
import type {
|
||||
EffortLevel,
|
||||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamFastMode,
|
||||
TeamLaunchRequest,
|
||||
} from '@shared/types/team';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const logger = createLogger('HTTP:teams');
|
||||
|
||||
type LaunchBody = Omit<TeamLaunchRequest, 'teamName'>;
|
||||
type CreateTeamBody = TeamCreateConfigRequest;
|
||||
|
||||
class HttpBadRequestError extends Error {}
|
||||
class HttpFeatureUnavailableError extends Error {}
|
||||
|
||||
function getTeamProvisioningService(services: HttpServices) {
|
||||
function getTeamProvisioningService(
|
||||
services: HttpServices
|
||||
): NonNullable<HttpServices['teamProvisioningService']> {
|
||||
if (!services.teamProvisioningService) {
|
||||
throw new HttpFeatureUnavailableError('Team runtime control is not available in this mode');
|
||||
}
|
||||
return services.teamProvisioningService;
|
||||
}
|
||||
|
||||
function getTeamDataService(services: HttpServices): NonNullable<HttpServices['teamDataService']> {
|
||||
if (!services.teamDataService) {
|
||||
throw new HttpFeatureUnavailableError('Team data control is not available in this mode');
|
||||
}
|
||||
return services.teamDataService;
|
||||
}
|
||||
|
||||
function getStatusCode(error: unknown, fallback: number = 500): number {
|
||||
if (error instanceof HttpBadRequestError) {
|
||||
return 400;
|
||||
|
|
@ -37,11 +57,35 @@ function getStatusCode(error: unknown, fallback: number = 500): number {
|
|||
if (error instanceof Error && error.name === 'RuntimeStaleEvidenceError') {
|
||||
return 409;
|
||||
}
|
||||
if (error instanceof Error && error.message.startsWith('Team not found')) {
|
||||
return 404;
|
||||
}
|
||||
if (error instanceof Error && error.message.startsWith('Team already exists')) {
|
||||
return 409;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function shouldLogError(error: unknown): boolean {
|
||||
return !(error instanceof HttpBadRequestError) && !(error instanceof HttpFeatureUnavailableError);
|
||||
const statusCode = getStatusCode(error);
|
||||
return (
|
||||
statusCode >= 500 &&
|
||||
!(error instanceof HttpBadRequestError) &&
|
||||
!(error instanceof HttpFeatureUnavailableError)
|
||||
);
|
||||
}
|
||||
|
||||
function assertProvisioningTeamName(value: unknown): string {
|
||||
const validated = validateTeamName(value);
|
||||
if (!validated.valid) {
|
||||
throw new HttpBadRequestError(validated.error ?? 'Invalid teamName');
|
||||
}
|
||||
const teamName = validated.value!;
|
||||
const parts = teamName.split('-');
|
||||
if (teamName.length > 64 || !parts.every((part) => /^[a-z0-9]+$/.test(part))) {
|
||||
throw new HttpBadRequestError('teamName must be kebab-case [a-z0-9-], max 64 chars');
|
||||
}
|
||||
return teamName;
|
||||
}
|
||||
|
||||
function assertAbsoluteCwd(cwd: unknown): string {
|
||||
|
|
@ -82,6 +126,40 @@ function assertOptionalBoolean(value: unknown, fieldName: string): boolean | und
|
|||
return value;
|
||||
}
|
||||
|
||||
function assertOptionalCwd(value: unknown): string | undefined {
|
||||
if (value == null) {
|
||||
return undefined;
|
||||
}
|
||||
const cwd = assertOptionalString(value, 'cwd');
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isAbsolute(cwd)) {
|
||||
throw new HttpBadRequestError('cwd must be an absolute path');
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
|
||||
function assertOptionalExtraCliArgs(value: unknown): string | undefined {
|
||||
const extraCliArgs = assertOptionalString(value, 'extraCliArgs');
|
||||
if (!extraCliArgs) {
|
||||
return undefined;
|
||||
}
|
||||
if (extraCliArgs.length > 1024) {
|
||||
throw new HttpBadRequestError('extraCliArgs too long (max 1024)');
|
||||
}
|
||||
|
||||
const protectedFlags = extractUserFlags(extraCliArgs).filter((flag) =>
|
||||
PROTECTED_CLI_FLAGS.has(flag)
|
||||
);
|
||||
if (protectedFlags.length > 0) {
|
||||
throw new HttpBadRequestError(
|
||||
`extraCliArgs contains app-managed flags: ${[...new Set(protectedFlags)].join(', ')}`
|
||||
);
|
||||
}
|
||||
return extraCliArgs;
|
||||
}
|
||||
|
||||
function assertOptionalEffort(
|
||||
value: unknown,
|
||||
providerId: TeamLaunchRequest['providerId']
|
||||
|
|
@ -111,33 +189,92 @@ function assertOptionalFastMode(value: unknown): TeamFastMode | undefined {
|
|||
return value;
|
||||
}
|
||||
|
||||
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
|
||||
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
||||
const providerId =
|
||||
payload.providerId == null
|
||||
? 'anthropic'
|
||||
: isTeamProviderId(payload.providerId)
|
||||
? payload.providerId
|
||||
: (() => {
|
||||
throw new HttpBadRequestError(
|
||||
'providerId must be anthropic, codex, gemini, or opencode'
|
||||
);
|
||||
})();
|
||||
const prompt = assertOptionalString(payload.prompt, 'prompt');
|
||||
const rawProviderBackendId = assertOptionalString(payload.providerBackendId, 'providerBackendId');
|
||||
function parseProviderId(value: unknown): TeamLaunchRequest['providerId'] {
|
||||
if (value == null) {
|
||||
return 'anthropic';
|
||||
}
|
||||
if (isTeamProviderId(value)) {
|
||||
return value;
|
||||
}
|
||||
throw new HttpBadRequestError('providerId must be anthropic, codex, gemini, or opencode');
|
||||
}
|
||||
|
||||
function parseProviderBackendId(
|
||||
providerId: TeamLaunchRequest['providerId'],
|
||||
value: unknown
|
||||
): TeamLaunchRequest['providerBackendId'] | undefined {
|
||||
const rawProviderBackendId = assertOptionalString(value, 'providerBackendId');
|
||||
const providerBackendId = migrateProviderBackendId(providerId, rawProviderBackendId);
|
||||
if (rawProviderBackendId && !providerBackendId) {
|
||||
throw new HttpBadRequestError(
|
||||
'providerBackendId must be one of auto, adapter, api, cli-sdk, or codex-native'
|
||||
);
|
||||
}
|
||||
return providerBackendId;
|
||||
}
|
||||
|
||||
function parseCreateMembers(payloadMembers: unknown): TeamCreateConfigRequest['members'] {
|
||||
if (payloadMembers == null) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(payloadMembers)) {
|
||||
throw new HttpBadRequestError('members must be an array');
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
return payloadMembers.map((member) => {
|
||||
if (!member || typeof member !== 'object') {
|
||||
throw new HttpBadRequestError('member must be object');
|
||||
}
|
||||
const rawMember = member as Record<string, unknown>;
|
||||
const nameValidation = validateTeammateName(rawMember.name);
|
||||
if (!nameValidation.valid) {
|
||||
throw new HttpBadRequestError(nameValidation.error ?? 'Invalid member name');
|
||||
}
|
||||
const name = nameValidation.value!;
|
||||
if (seenNames.has(name)) {
|
||||
throw new HttpBadRequestError('member names must be unique');
|
||||
}
|
||||
seenNames.add(name);
|
||||
|
||||
const role = assertOptionalString(rawMember.role, 'member role');
|
||||
const workflow = assertOptionalString(rawMember.workflow, 'member workflow');
|
||||
if (rawMember.isolation !== undefined && rawMember.isolation !== 'worktree') {
|
||||
throw new HttpBadRequestError('member isolation must be "worktree" when provided');
|
||||
}
|
||||
const providerId =
|
||||
rawMember.providerId == null ? undefined : parseProviderId(rawMember.providerId);
|
||||
const providerBackendId = parseProviderBackendId(providerId, rawMember.providerBackendId);
|
||||
const model = assertOptionalString(rawMember.model, 'member model');
|
||||
const effort = assertOptionalEffort(rawMember.effort, providerId);
|
||||
const fastMode = assertOptionalFastMode(rawMember.fastMode);
|
||||
|
||||
return {
|
||||
name,
|
||||
...(role ? { role } : {}),
|
||||
...(workflow ? { workflow } : {}),
|
||||
...(rawMember.isolation === 'worktree' ? { isolation: 'worktree' as const } : {}),
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(providerBackendId ? { providerBackendId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(fastMode ? { fastMode } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest {
|
||||
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
||||
const providerId = parseProviderId(payload.providerId);
|
||||
const prompt = assertOptionalString(payload.prompt, 'prompt');
|
||||
const providerBackendId = parseProviderBackendId(providerId, payload.providerBackendId);
|
||||
const model = assertOptionalString(payload.model, 'model');
|
||||
const effort = assertOptionalEffort(payload.effort, providerId);
|
||||
const fastMode = assertOptionalFastMode(payload.fastMode);
|
||||
const clearContext = assertOptionalBoolean(payload.clearContext, 'clearContext');
|
||||
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
|
||||
const worktree = assertOptionalString(payload.worktree, 'worktree');
|
||||
const extraCliArgs = assertOptionalString(payload.extraCliArgs, 'extraCliArgs');
|
||||
const extraCliArgs = assertOptionalExtraCliArgs(payload.extraCliArgs);
|
||||
|
||||
return {
|
||||
teamName,
|
||||
|
|
@ -173,6 +310,150 @@ function parseLaunchRequest(teamName: string, body: unknown): TeamLaunchRequest
|
|||
};
|
||||
}
|
||||
|
||||
function parseCreateTeamRequest(body: unknown): TeamCreateConfigRequest {
|
||||
const payload = body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
||||
const teamName = assertProvisioningTeamName(payload.teamName);
|
||||
const providerId = payload.providerId == null ? undefined : parseProviderId(payload.providerId);
|
||||
const providerBackendId = parseProviderBackendId(providerId, payload.providerBackendId);
|
||||
const displayName = assertOptionalString(payload.displayName, 'displayName');
|
||||
const description = assertOptionalString(payload.description, 'description');
|
||||
const color = assertOptionalString(payload.color, 'color');
|
||||
const cwd = assertOptionalCwd(payload.cwd);
|
||||
const prompt = assertOptionalString(payload.prompt, 'prompt');
|
||||
const model = assertOptionalString(payload.model, 'model');
|
||||
const effort = assertOptionalEffort(payload.effort, providerId);
|
||||
const fastMode = assertOptionalFastMode(payload.fastMode);
|
||||
const limitContext = assertOptionalBoolean(payload.limitContext, 'limitContext');
|
||||
const skipPermissions = assertOptionalBoolean(payload.skipPermissions, 'skipPermissions');
|
||||
const worktree = assertOptionalString(payload.worktree, 'worktree');
|
||||
const extraCliArgs = assertOptionalExtraCliArgs(payload.extraCliArgs);
|
||||
|
||||
return {
|
||||
teamName,
|
||||
members: parseCreateMembers(payload.members),
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(color ? { color } : {}),
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(prompt ? { prompt } : {}),
|
||||
...(providerId ? { providerId } : {}),
|
||||
...(providerBackendId ? { providerBackendId } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effort ? { effort } : {}),
|
||||
...(fastMode ? { fastMode } : {}),
|
||||
...(limitContext !== undefined ? { limitContext } : {}),
|
||||
...(skipPermissions !== undefined ? { skipPermissions } : {}),
|
||||
...(worktree ? { worktree } : {}),
|
||||
...(extraCliArgs ? { extraCliArgs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function getObjectPayload(body: unknown): Record<string, unknown> {
|
||||
return body && typeof body === 'object' ? (body as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function pickOptionalString(
|
||||
payload: Record<string, unknown>,
|
||||
key: string,
|
||||
fallback: string | undefined,
|
||||
fieldName: string
|
||||
): string | undefined {
|
||||
return Object.hasOwn(payload, key) ? assertOptionalString(payload[key], fieldName) : fallback;
|
||||
}
|
||||
|
||||
function pickOptionalBoolean(
|
||||
payload: Record<string, unknown>,
|
||||
key: string,
|
||||
fallback: boolean | undefined,
|
||||
fieldName: string
|
||||
): boolean | undefined {
|
||||
return Object.hasOwn(payload, key) ? assertOptionalBoolean(payload[key], fieldName) : fallback;
|
||||
}
|
||||
|
||||
function parseDraftLaunchCreateRequest(
|
||||
savedRequest: TeamCreateRequest,
|
||||
body: unknown
|
||||
): TeamCreateRequest {
|
||||
const payload = getObjectPayload(body);
|
||||
const cwd = Object.hasOwn(payload, 'cwd') ? assertAbsoluteCwd(payload.cwd) : savedRequest.cwd;
|
||||
if (!cwd) {
|
||||
throw new HttpBadRequestError('cwd is required');
|
||||
}
|
||||
|
||||
const providerId = Object.hasOwn(payload, 'providerId')
|
||||
? parseProviderId(payload.providerId)
|
||||
: (savedRequest.providerId ?? 'anthropic');
|
||||
const providerBackendId = parseProviderBackendId(
|
||||
providerId,
|
||||
Object.hasOwn(payload, 'providerBackendId')
|
||||
? payload.providerBackendId
|
||||
: savedRequest.providerBackendId
|
||||
);
|
||||
const effort = assertOptionalEffort(
|
||||
Object.hasOwn(payload, 'effort') ? payload.effort : savedRequest.effort,
|
||||
providerId
|
||||
);
|
||||
const fastMode = Object.hasOwn(payload, 'fastMode')
|
||||
? assertOptionalFastMode(payload.fastMode)
|
||||
: savedRequest.fastMode;
|
||||
const extraCliArgs = Object.hasOwn(payload, 'extraCliArgs')
|
||||
? assertOptionalExtraCliArgs(payload.extraCliArgs)
|
||||
: savedRequest.extraCliArgs;
|
||||
if (extraCliArgs) {
|
||||
assertOptionalExtraCliArgs(extraCliArgs);
|
||||
}
|
||||
|
||||
return {
|
||||
teamName: savedRequest.teamName,
|
||||
displayName: savedRequest.displayName,
|
||||
description: savedRequest.description,
|
||||
color: savedRequest.color,
|
||||
members: savedRequest.members,
|
||||
cwd,
|
||||
prompt: pickOptionalString(payload, 'prompt', savedRequest.prompt, 'prompt'),
|
||||
providerId,
|
||||
...(providerBackendId ? { providerBackendId } : {}),
|
||||
model: pickOptionalString(payload, 'model', savedRequest.model, 'model'),
|
||||
...(effort ? { effort } : {}),
|
||||
...(fastMode ? { fastMode } : {}),
|
||||
limitContext: pickOptionalBoolean(
|
||||
payload,
|
||||
'limitContext',
|
||||
savedRequest.limitContext,
|
||||
'limitContext'
|
||||
),
|
||||
skipPermissions: pickOptionalBoolean(
|
||||
payload,
|
||||
'skipPermissions',
|
||||
savedRequest.skipPermissions,
|
||||
'skipPermissions'
|
||||
),
|
||||
worktree: pickOptionalString(payload, 'worktree', savedRequest.worktree, 'worktree'),
|
||||
...(extraCliArgs ? { extraCliArgs } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function getDraftSavedRequest(
|
||||
services: HttpServices,
|
||||
teamName: string
|
||||
): Promise<TeamCreateRequest | null> {
|
||||
if (!services.teamDataService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configPath = join(getTeamsBasePath(), teamName, 'config.json');
|
||||
try {
|
||||
await access(configPath, fsConstants.F_OK);
|
||||
return null;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return getTeamDataService(services).getSavedRequest(teamName);
|
||||
}
|
||||
|
||||
function withRuntimeTeamName(teamName: string, body: unknown): Record<string, unknown> {
|
||||
const payload =
|
||||
body && typeof body === 'object' && !Array.isArray(body)
|
||||
|
|
@ -186,6 +467,56 @@ function withRuntimeTeamName(teamName: string, body: unknown): Record<string, un
|
|||
}
|
||||
|
||||
export function registerTeamRoutes(app: FastifyInstance, services: HttpServices): void {
|
||||
app.get('/api/teams', async (_request, reply) => {
|
||||
try {
|
||||
return reply.send(await getTeamDataService(services).listTeams());
|
||||
} catch (error) {
|
||||
if (shouldLogError(error)) {
|
||||
logger.error('Error in GET /api/teams:', getErrorMessage(error));
|
||||
}
|
||||
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: CreateTeamBody }>('/api/teams', async (request, reply) => {
|
||||
try {
|
||||
const createRequest = parseCreateTeamRequest(request.body);
|
||||
await getTeamDataService(services).createTeamConfig(createRequest);
|
||||
return reply.status(201).send({ teamName: createRequest.teamName });
|
||||
} catch (error) {
|
||||
if (shouldLogError(error)) {
|
||||
logger.error('Error in POST /api/teams:', getErrorMessage(error));
|
||||
}
|
||||
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { teamName: string } }>('/api/teams/:teamName', async (request, reply) => {
|
||||
try {
|
||||
const validatedTeamName = validateTeamName(request.params.teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return reply.status(400).send({ error: validatedTeamName.error });
|
||||
}
|
||||
|
||||
const teamName = validatedTeamName.value!;
|
||||
const draftSavedRequest = await getDraftSavedRequest(services, teamName);
|
||||
if (draftSavedRequest) {
|
||||
return reply.send({
|
||||
teamName,
|
||||
pendingCreate: true,
|
||||
savedRequest: draftSavedRequest,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send(await getTeamDataService(services).getTeamData(teamName));
|
||||
} catch (error) {
|
||||
if (shouldLogError(error)) {
|
||||
logger.error(`Error in GET /api/teams/${request.params.teamName}:`, getErrorMessage(error));
|
||||
}
|
||||
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { teamName: string }; Body: LaunchBody }>(
|
||||
'/api/teams/:teamName/launch',
|
||||
async (request, reply) => {
|
||||
|
|
@ -195,11 +526,17 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
|
|||
return reply.status(400).send({ error: validatedTeamName.error });
|
||||
}
|
||||
|
||||
const launchRequest = parseLaunchRequest(validatedTeamName.value!, request.body);
|
||||
const response = await getTeamProvisioningService(services).launchTeam(
|
||||
launchRequest,
|
||||
() => undefined
|
||||
);
|
||||
const teamName = validatedTeamName.value!;
|
||||
const draftSavedRequest = await getDraftSavedRequest(services, teamName);
|
||||
const response = draftSavedRequest
|
||||
? await getTeamProvisioningService(services).createTeam(
|
||||
parseDraftLaunchCreateRequest(draftSavedRequest, request.body),
|
||||
() => undefined
|
||||
)
|
||||
: await getTeamProvisioningService(services).launchTeam(
|
||||
parseLaunchRequest(teamName, request.body),
|
||||
() => undefined
|
||||
);
|
||||
return reply.send(response);
|
||||
} catch (error) {
|
||||
const statusCode = getStatusCode(error);
|
||||
|
|
|
|||
|
|
@ -1337,6 +1337,7 @@ async function startHttpServer(
|
|||
recentProjectsFeature,
|
||||
updaterService,
|
||||
sshConnectionManager,
|
||||
teamDataService,
|
||||
teamProvisioningService,
|
||||
},
|
||||
modeSwitchHandler,
|
||||
|
|
|
|||
|
|
@ -1222,8 +1222,9 @@ function isValidEffort(value: unknown, providerId?: TeamProviderId | null): valu
|
|||
return isTeamEffortLevelForProvider(value, providerId);
|
||||
}
|
||||
|
||||
function parseOptionalMemberProviderId(
|
||||
value: unknown
|
||||
function parseOptionalProviderId(
|
||||
value: unknown,
|
||||
fieldName: string
|
||||
): { valid: true; value: TeamProviderId | undefined } | { valid: false; error: string } {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { valid: true, value: undefined };
|
||||
|
|
@ -1231,7 +1232,19 @@ function parseOptionalMemberProviderId(
|
|||
if (isTeamProviderId(value)) {
|
||||
return { valid: true, value };
|
||||
}
|
||||
return { valid: false, error: 'member providerId must be anthropic, codex, gemini, or opencode' };
|
||||
return { valid: false, error: `${fieldName} must be anthropic, codex, gemini, or opencode` };
|
||||
}
|
||||
|
||||
function parseOptionalMemberProviderId(
|
||||
value: unknown
|
||||
): { valid: true; value: TeamProviderId | undefined } | { valid: false; error: string } {
|
||||
return parseOptionalProviderId(value, 'member providerId');
|
||||
}
|
||||
|
||||
function parseOptionalTeamProviderId(
|
||||
value: unknown
|
||||
): { valid: true; value: TeamProviderId | undefined } | { valid: false; error: string } {
|
||||
return parseOptionalProviderId(value, 'providerId');
|
||||
}
|
||||
|
||||
function parseOptionalProviderBackendId(
|
||||
|
|
@ -1611,6 +1624,13 @@ async function validateProvisioningRequest(
|
|||
if (!providerValidation.valid) {
|
||||
return { valid: false, error: providerValidation.error };
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
(member as { providerBackendId?: unknown }).providerBackendId,
|
||||
providerValidation.value
|
||||
);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { valid: false, error: providerBackendValidation.error };
|
||||
}
|
||||
const model = (member as { model?: unknown }).model;
|
||||
if (model !== undefined && typeof model !== 'string') {
|
||||
return { valid: false, error: 'member model must be string' };
|
||||
|
|
@ -1622,14 +1642,22 @@ async function validateProvisioningRequest(
|
|||
if (!effortValidation.valid) {
|
||||
return { valid: false, error: effortValidation.error };
|
||||
}
|
||||
const fastModeValidation = parseOptionalTeamFastMode(
|
||||
(member as { fastMode?: unknown }).fastMode
|
||||
);
|
||||
if (!fastModeValidation.valid) {
|
||||
return { valid: false, error: fastModeValidation.error };
|
||||
}
|
||||
members.push({
|
||||
name: memberName,
|
||||
role: typeof role === 'string' ? role.trim() : undefined,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||||
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: providerValidation.value,
|
||||
providerBackendId: providerBackendValidation.value,
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
fastMode: fastModeValidation.value,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1858,61 +1886,60 @@ async function handleLaunchTeam(
|
|||
}
|
||||
|
||||
if (isDraft) {
|
||||
const meta = await teamMetaStore.getMeta(tn);
|
||||
const membersStore = new TeamMembersMetaStore();
|
||||
const membersMeta = await membersStore.getMeta(tn);
|
||||
const members = membersMeta?.members ?? [];
|
||||
const savedRequest = await getTeamDataService().getSavedRequest(tn);
|
||||
if (!savedRequest) {
|
||||
return { success: false, error: `Missing saved request for draft team: ${tn}` };
|
||||
}
|
||||
|
||||
const resolvedProviderId =
|
||||
providerId === 'codex' || providerId === 'gemini'
|
||||
? providerId
|
||||
: meta?.providerId === 'codex'
|
||||
? 'codex'
|
||||
: meta?.providerId === 'gemini'
|
||||
? 'gemini'
|
||||
: 'anthropic';
|
||||
const effortValidation = parseOptionalTeamEffort(payload.effort, resolvedProviderId);
|
||||
const resolvedProviderId = explicitProviderId ?? savedRequest.providerId ?? providerId;
|
||||
const effortValidation = parseOptionalTeamEffort(
|
||||
payload.effort ?? savedRequest.effort,
|
||||
resolvedProviderId
|
||||
);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode);
|
||||
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode ?? savedRequest.fastMode);
|
||||
if (!fastModeValidation.valid) {
|
||||
return { success: false, error: fastModeValidation.error };
|
||||
}
|
||||
|
||||
const createRequest: TeamCreateRequest = {
|
||||
teamName: tn,
|
||||
displayName: meta?.displayName,
|
||||
description: meta?.description,
|
||||
color: meta?.color,
|
||||
displayName: savedRequest.displayName,
|
||||
description: savedRequest.description,
|
||||
color: savedRequest.color,
|
||||
cwd,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
prompt:
|
||||
typeof payload.prompt === 'string'
|
||||
? payload.prompt.trim() || undefined
|
||||
: savedRequest.prompt,
|
||||
providerId: resolvedProviderId,
|
||||
providerBackendId: migrateProviderBackendId(
|
||||
resolvedProviderId,
|
||||
providerBackendValidation.value ?? meta?.providerBackendId ?? membersMeta?.providerBackendId
|
||||
providerBackendValidation.value ?? savedRequest.providerBackendId
|
||||
),
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
model:
|
||||
typeof payload.model === 'string' ? payload.model.trim() || undefined : savedRequest.model,
|
||||
effort: effortValidation.value,
|
||||
fastMode: fastModeValidation.value ?? meta?.fastMode,
|
||||
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
|
||||
fastMode: fastModeValidation.value,
|
||||
limitContext:
|
||||
typeof payload.limitContext === 'boolean'
|
||||
? payload.limitContext
|
||||
: savedRequest.limitContext,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
typeof payload.skipPermissions === 'boolean'
|
||||
? payload.skipPermissions
|
||||
: savedRequest.skipPermissions,
|
||||
worktree:
|
||||
typeof payload.worktree === 'string' ? payload.worktree.trim() || undefined : undefined,
|
||||
typeof payload.worktree === 'string'
|
||||
? payload.worktree.trim() || undefined
|
||||
: savedRequest.worktree,
|
||||
extraCliArgs:
|
||||
typeof payload.extraCliArgs === 'string'
|
||||
? payload.extraCliArgs.trim() || undefined
|
||||
: undefined,
|
||||
members: members.map((m) => ({
|
||||
name: m.name,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
isolation: m.isolation,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
})),
|
||||
: savedRequest.extraCliArgs,
|
||||
members: savedRequest.members,
|
||||
};
|
||||
|
||||
return wrapTeamHandler('create', () =>
|
||||
|
|
@ -3147,14 +3174,69 @@ async function handleCreateConfig(
|
|||
return { success: false, error: 'cwd must be an absolute path' };
|
||||
}
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(payload.providerBackendId);
|
||||
if (payload.prompt !== undefined && typeof payload.prompt !== 'string') {
|
||||
return { success: false, error: 'prompt must be a string' };
|
||||
}
|
||||
const providerValidation = parseOptionalTeamProviderId(payload.providerId);
|
||||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
payload.providerBackendId,
|
||||
providerValidation.value
|
||||
);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { success: false, error: providerBackendValidation.error };
|
||||
}
|
||||
if (payload.model !== undefined && typeof payload.model !== 'string') {
|
||||
return { success: false, error: 'model must be a string' };
|
||||
}
|
||||
const effortValidation = parseOptionalTeamEffort(payload.effort, providerValidation.value);
|
||||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
const fastModeValidation = parseOptionalTeamFastMode(payload.fastMode);
|
||||
if (!fastModeValidation.valid) {
|
||||
return { success: false, error: fastModeValidation.error };
|
||||
}
|
||||
if (payload.limitContext !== undefined && typeof payload.limitContext !== 'boolean') {
|
||||
return { success: false, error: 'limitContext must be a boolean' };
|
||||
}
|
||||
if (payload.skipPermissions !== undefined && typeof payload.skipPermissions !== 'boolean') {
|
||||
return { success: false, error: 'skipPermissions must be a boolean' };
|
||||
}
|
||||
if (payload.worktree !== undefined) {
|
||||
if (typeof payload.worktree !== 'string') {
|
||||
return { success: false, error: 'worktree must be a string' };
|
||||
}
|
||||
const worktree = payload.worktree.trim();
|
||||
if (worktree.length > 128) {
|
||||
return { success: false, error: 'worktree name too long (max 128)' };
|
||||
}
|
||||
if (worktree && !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(worktree)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'worktree name: start with alphanumeric, use [a-zA-Z0-9._-]',
|
||||
};
|
||||
}
|
||||
}
|
||||
if (payload.extraCliArgs !== undefined) {
|
||||
if (typeof payload.extraCliArgs !== 'string') {
|
||||
return { success: false, error: 'extraCliArgs must be a string' };
|
||||
}
|
||||
if (payload.extraCliArgs.length > 1024) {
|
||||
return { success: false, error: 'extraCliArgs too long (max 1024)' };
|
||||
}
|
||||
const protectedFlags = extractUserFlags(payload.extraCliArgs).filter((flag) =>
|
||||
PROTECTED_CLI_FLAGS.has(flag)
|
||||
);
|
||||
if (protectedFlags.length > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: `extraCliArgs contains app-managed flags: ${[...new Set(protectedFlags)].join(', ')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const members: TeamCreateConfigRequest['members'] = [];
|
||||
|
|
@ -3190,6 +3272,13 @@ async function handleCreateConfig(
|
|||
if (!providerValidation.valid) {
|
||||
return { success: false, error: providerValidation.error };
|
||||
}
|
||||
const providerBackendValidation = parseOptionalProviderBackendId(
|
||||
(member as { providerBackendId?: unknown }).providerBackendId,
|
||||
providerValidation.value
|
||||
);
|
||||
if (!providerBackendValidation.valid) {
|
||||
return { success: false, error: providerBackendValidation.error };
|
||||
}
|
||||
const model = (member as { model?: unknown }).model;
|
||||
if (model !== undefined && typeof model !== 'string') {
|
||||
return { success: false, error: 'member model must be string' };
|
||||
|
|
@ -3201,14 +3290,22 @@ async function handleCreateConfig(
|
|||
if (!effortValidation.valid) {
|
||||
return { success: false, error: effortValidation.error };
|
||||
}
|
||||
const fastModeValidation = parseOptionalTeamFastMode(
|
||||
(member as { fastMode?: unknown }).fastMode
|
||||
);
|
||||
if (!fastModeValidation.valid) {
|
||||
return { success: false, error: fastModeValidation.error };
|
||||
}
|
||||
members.push({
|
||||
name: memberName,
|
||||
role: typeof role === 'string' ? role.trim() : undefined,
|
||||
workflow: typeof workflow === 'string' ? workflow.trim() : undefined,
|
||||
isolation: isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: providerValidation.value,
|
||||
providerBackendId: providerBackendValidation.value,
|
||||
model: typeof model === 'string' ? model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
fastMode: fastModeValidation.value,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3220,8 +3317,23 @@ async function handleCreateConfig(
|
|||
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
|
||||
members,
|
||||
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
|
||||
prompt: typeof payload.prompt === 'string' ? payload.prompt.trim() || undefined : undefined,
|
||||
providerId: providerValidation.value,
|
||||
providerBackendId: providerBackendValidation.value,
|
||||
model: typeof payload.model === 'string' ? payload.model.trim() || undefined : undefined,
|
||||
effort: effortValidation.value,
|
||||
fastMode: fastModeValidation.value,
|
||||
limitContext: typeof payload.limitContext === 'boolean' ? payload.limitContext : undefined,
|
||||
skipPermissions:
|
||||
typeof payload.skipPermissions === 'boolean' ? payload.skipPermissions : undefined,
|
||||
worktree:
|
||||
typeof payload.worktree === 'string' && payload.worktree.trim()
|
||||
? payload.worktree.trim()
|
||||
: undefined,
|
||||
extraCliArgs:
|
||||
typeof payload.extraCliArgs === 'string' && payload.extraCliArgs.trim()
|
||||
? payload.extraCliArgs.trim()
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -4696,52 +4808,9 @@ async function handleGetSavedRequest(
|
|||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const tn = validated.value!;
|
||||
|
||||
const meta = await teamMetaStore.getMeta(tn);
|
||||
if (!meta) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
|
||||
const membersStore = new TeamMembersMetaStore();
|
||||
const membersMeta = await membersStore.getMeta(tn);
|
||||
const members = membersMeta?.members ?? [];
|
||||
|
||||
const resolvedProviderId = meta.providerId ?? 'anthropic';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
teamName: tn,
|
||||
displayName: meta.displayName,
|
||||
description: meta.description,
|
||||
color: meta.color,
|
||||
cwd: meta.cwd,
|
||||
prompt: meta.prompt,
|
||||
providerId: resolvedProviderId,
|
||||
providerBackendId: migrateProviderBackendId(
|
||||
resolvedProviderId,
|
||||
meta.providerBackendId ?? membersMeta?.providerBackendId
|
||||
),
|
||||
model: meta.model,
|
||||
effort: meta.effort as TeamCreateRequest['effort'],
|
||||
fastMode: meta.fastMode,
|
||||
skipPermissions: meta.skipPermissions,
|
||||
worktree: meta.worktree,
|
||||
extraCliArgs: meta.extraCliArgs,
|
||||
limitContext: meta.limitContext,
|
||||
members: members.map((m) => ({
|
||||
name: m.name,
|
||||
role: m.role,
|
||||
workflow: m.workflow,
|
||||
isolation: m.isolation,
|
||||
cwd: m.cwd,
|
||||
providerId: m.providerId,
|
||||
model: m.model,
|
||||
effort: m.effort,
|
||||
})),
|
||||
},
|
||||
};
|
||||
return wrapTeamHandler('getSavedRequest', async () => {
|
||||
return getTeamDataService().getSavedRequest(validated.value!);
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteDraft(
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@ import {
|
|||
} from '@shared/utils/taskChangeState';
|
||||
import { createHash } from 'crypto';
|
||||
import { existsSync } from 'fs';
|
||||
import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises';
|
||||
import { chmod, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository';
|
||||
import { OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION } from './opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
import {
|
||||
getOpenCodeLaneScopedRuntimeFilePath,
|
||||
getOpenCodeTeamRuntimeDirectory,
|
||||
|
|
@ -46,7 +47,7 @@ import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types
|
|||
|
||||
const logger = createLogger('Service:ChangeExtractorService');
|
||||
const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const;
|
||||
const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE = 'chain-only' as const;
|
||||
const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE = 'opencode-session-snapshot-v1' as const;
|
||||
const OPEN_CODE_MAX_DISCOVERED_LANES = 500;
|
||||
|
||||
/** Кеш-запись: данные + mtime файла + время протухания */
|
||||
|
|
@ -71,18 +72,29 @@ interface OpenCodeBackfillCacheEntry {
|
|||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface OpenCodeBackfillAttempt {
|
||||
attempted: boolean;
|
||||
backfilled: boolean;
|
||||
}
|
||||
|
||||
interface OpenCodeDeliveryContextTempFile {
|
||||
filePath: string | null;
|
||||
hash: string | null;
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface OpenCodeDeliveryContextPayload {
|
||||
rawContext: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export class ChangeExtractorService {
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private taskChangeSummaryCache = new Map<string, TaskChangeSummaryCacheEntry>();
|
||||
private taskChangeSummaryInFlight = new Map<string, Promise<TaskChangeSetV2>>();
|
||||
private taskChangeSummaryVersionByTask = new Map<string, number>();
|
||||
private taskChangeSummaryValidationInFlight = new Set<string>();
|
||||
private openCodeBackfillInFlight = new Map<string, Promise<boolean>>();
|
||||
private openCodeBackfillInFlight = new Map<string, Promise<OpenCodeBackfillAttempt>>();
|
||||
private openCodeBackfillCache = new Map<string, OpenCodeBackfillCacheEntry>();
|
||||
private openCodeTeamEligibilityCache = new Map<string, { value: boolean; expiresAt: number }>();
|
||||
private readonly cacheTtl = 30 * 1000; // 30 сек — shorter TTL to reduce stale data risk
|
||||
|
|
@ -210,7 +222,8 @@ export class ChangeExtractorService {
|
|||
return ledgerResult;
|
||||
}
|
||||
|
||||
if (await this.tryBackfillOpenCodeLedger(resolvedInput)) {
|
||||
const openCodeBackfill = await this.tryBackfillOpenCodeLedger(resolvedInput);
|
||||
if (openCodeBackfill.backfilled || openCodeBackfill.attempted) {
|
||||
const backfilledLedgerResult = await this.readLedgerTaskChanges(resolvedInput);
|
||||
if (backfilledLedgerResult) {
|
||||
await this.recordTaskChangePresence(
|
||||
|
|
@ -379,15 +392,17 @@ export class ChangeExtractorService {
|
|||
}
|
||||
}
|
||||
|
||||
private async tryBackfillOpenCodeLedger(input: ResolvedTaskChangeComputeInput): Promise<boolean> {
|
||||
private async tryBackfillOpenCodeLedger(
|
||||
input: ResolvedTaskChangeComputeInput
|
||||
): Promise<OpenCodeBackfillAttempt> {
|
||||
if (!this.openCodeLedgerBackfillPort) {
|
||||
return false;
|
||||
return { attempted: false, backfilled: false };
|
||||
}
|
||||
if (!(await this.isOpenCodeTeamCandidate(input.teamName))) {
|
||||
return false;
|
||||
return { attempted: false, backfilled: false };
|
||||
}
|
||||
if (typeof this.logsFinder.getLogSourceWatchContext !== 'function') {
|
||||
return false;
|
||||
return { attempted: false, backfilled: false };
|
||||
}
|
||||
|
||||
const context = await this.logsFinder
|
||||
|
|
@ -401,7 +416,7 @@ export class ChangeExtractorService {
|
|||
!path.isAbsolute(projectDir) ||
|
||||
!path.isAbsolute(workspaceRoot)
|
||||
) {
|
||||
return false;
|
||||
return { attempted: false, backfilled: false };
|
||||
}
|
||||
|
||||
const sourceGeneration = this.teamLogSourceTracker
|
||||
|
|
@ -414,8 +429,16 @@ export class ChangeExtractorService {
|
|||
input.teamName,
|
||||
input.taskId
|
||||
);
|
||||
const deliveryContextFingerprint =
|
||||
this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords);
|
||||
const deliveryContextPayload = this.buildOpenCodeDeliveryContextPayload(
|
||||
input.teamName,
|
||||
input.taskId,
|
||||
deliveryContextRecords
|
||||
);
|
||||
const backfillMemberName = this.resolveOpenCodeBackfillMemberName(
|
||||
input.effectiveOptions.owner,
|
||||
deliveryContextRecords
|
||||
);
|
||||
const deliveryContextFingerprint = deliveryContextPayload.hash;
|
||||
|
||||
const cacheKey = this.buildOpenCodeBackfillCacheKey({
|
||||
teamName: input.teamName,
|
||||
|
|
@ -426,12 +449,12 @@ export class ChangeExtractorService {
|
|||
sourceGeneration,
|
||||
deliveryContextFingerprint,
|
||||
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
|
||||
evidenceMode: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE,
|
||||
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
|
||||
});
|
||||
const now = Date.now();
|
||||
const cached = this.openCodeBackfillCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
return cached.backfilledAt > 0;
|
||||
return { attempted: false, backfilled: cached.backfilledAt > 0 };
|
||||
}
|
||||
this.openCodeBackfillCache.delete(cacheKey);
|
||||
|
||||
|
|
@ -446,15 +469,16 @@ export class ChangeExtractorService {
|
|||
teamName: input.teamName,
|
||||
taskId: input.taskId,
|
||||
displayId: input.taskMeta?.displayId ?? null,
|
||||
memberName: input.effectiveOptions.owner ?? null,
|
||||
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
|
||||
projectDir,
|
||||
workspaceRoot,
|
||||
sourceGeneration,
|
||||
deliveryRecordCount: 0,
|
||||
deliveryContextFingerprint,
|
||||
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
|
||||
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
|
||||
}).catch(() => undefined);
|
||||
return false;
|
||||
return { attempted: false, backfilled: false };
|
||||
}
|
||||
|
||||
const existing = this.openCodeBackfillInFlight.get(cacheKey);
|
||||
|
|
@ -468,7 +492,9 @@ export class ChangeExtractorService {
|
|||
workspaceRoot,
|
||||
cacheKey,
|
||||
deliveryContextRecords,
|
||||
sourceGeneration
|
||||
deliveryContextPayload,
|
||||
sourceGeneration,
|
||||
backfillMemberName
|
||||
).finally(() => {
|
||||
this.openCodeBackfillInFlight.delete(cacheKey);
|
||||
});
|
||||
|
|
@ -484,12 +510,15 @@ export class ChangeExtractorService {
|
|||
deliveryContextRecords: Awaited<
|
||||
ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>
|
||||
>,
|
||||
sourceGeneration: string | null
|
||||
): Promise<boolean> {
|
||||
deliveryContextPayload: OpenCodeDeliveryContextPayload,
|
||||
sourceGeneration: string | null,
|
||||
backfillMemberName?: string
|
||||
): Promise<OpenCodeBackfillAttempt> {
|
||||
const deliveryContext = await this.createOpenCodeDeliveryContextTempFile(
|
||||
input.teamName,
|
||||
input.taskId,
|
||||
deliveryContextRecords
|
||||
deliveryContextRecords,
|
||||
deliveryContextPayload
|
||||
);
|
||||
try {
|
||||
const result = await this.openCodeLedgerBackfillPort!.backfillOpenCodeTaskLedger({
|
||||
|
|
@ -497,26 +526,48 @@ export class ChangeExtractorService {
|
|||
teamName: input.teamName,
|
||||
taskId: input.taskId,
|
||||
taskDisplayId: input.taskMeta?.displayId,
|
||||
memberName: input.effectiveOptions.owner,
|
||||
projectDir,
|
||||
workspaceRoot,
|
||||
attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
|
||||
evidenceMode: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE,
|
||||
...(deliveryContext.filePath ? { deliveryContextPath: deliveryContext.filePath } : {}),
|
||||
...(backfillMemberName ? { memberName: backfillMemberName } : {}),
|
||||
...(deliveryContext.filePath
|
||||
? {
|
||||
deliveryContextPath: deliveryContext.filePath,
|
||||
deliveryContextHash: deliveryContext.hash ?? undefined,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const evidenceContractVersion =
|
||||
typeof result.opencodeTaskLedgerEvidenceContractVersion === 'number' &&
|
||||
Number.isInteger(result.opencodeTaskLedgerEvidenceContractVersion)
|
||||
? result.opencodeTaskLedgerEvidenceContractVersion
|
||||
: 0;
|
||||
const hasExpectedEvidenceContract =
|
||||
evidenceContractVersion >= OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION;
|
||||
const diagnostics = hasExpectedEvidenceContract
|
||||
? (result.diagnostics ?? [])
|
||||
: [
|
||||
`OpenCode task ledger evidence contract is unsupported or missing: ${evidenceContractVersion}.`,
|
||||
...(result.diagnostics ?? []),
|
||||
];
|
||||
void appendOpenCodeTaskChangeDiag({
|
||||
event: 'backfill_result',
|
||||
reason: this.classifyOpenCodeBackfillResult(result),
|
||||
reason:
|
||||
!hasExpectedEvidenceContract && result.importedEvents <= 0
|
||||
? 'unsupported-evidence-contract'
|
||||
: this.classifyOpenCodeBackfillResult(result),
|
||||
teamName: input.teamName,
|
||||
taskId: input.taskId,
|
||||
displayId: input.taskMeta?.displayId ?? null,
|
||||
memberName: input.effectiveOptions.owner ?? null,
|
||||
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
|
||||
projectDir,
|
||||
workspaceRoot,
|
||||
sourceGeneration,
|
||||
deliveryRecordCount: deliveryContextRecords.length,
|
||||
deliveryContextFingerprint: this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords),
|
||||
deliveryContextFingerprint: deliveryContextPayload.hash,
|
||||
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
|
||||
result: {
|
||||
opencodeTaskLedgerEvidenceContractVersion: evidenceContractVersion,
|
||||
attributionMode: result.attributionMode ?? OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE,
|
||||
outcome: result.outcome,
|
||||
dryRun: result.dryRun,
|
||||
|
|
@ -526,13 +577,14 @@ export class ChangeExtractorService {
|
|||
importedEvents: result.importedEvents,
|
||||
skippedEvents: result.skippedEvents,
|
||||
},
|
||||
diagnostics: (result.diagnostics ?? []).slice(0, 25),
|
||||
diagnostics: diagnostics.slice(0, 25),
|
||||
notices: (result.notices ?? []).slice(0, 25),
|
||||
}).catch(() => undefined);
|
||||
const backfilled =
|
||||
result.importedEvents > 0 ||
|
||||
result.outcome === 'imported' ||
|
||||
(result.outcome === 'duplicates-only' && result.candidateEvents > 0);
|
||||
(hasExpectedEvidenceContract &&
|
||||
(result.outcome === 'imported' ||
|
||||
(result.outcome === 'duplicates-only' && result.candidateEvents > 0)));
|
||||
|
||||
if (result.importedEvents > 0) {
|
||||
await this.invalidateTaskChangeSummaries(input.teamName, [input.taskId], {
|
||||
|
|
@ -540,7 +592,7 @@ export class ChangeExtractorService {
|
|||
});
|
||||
}
|
||||
|
||||
if (backfilled || deliveryContextRecords.length === 0) {
|
||||
if ((hasExpectedEvidenceContract && backfilled) || deliveryContextRecords.length === 0) {
|
||||
this.openCodeBackfillCache.set(cacheKey, {
|
||||
backfilledAt: backfilled ? Date.now() : 0,
|
||||
expiresAt: Date.now() + this.openCodeBackfillCacheTtl,
|
||||
|
|
@ -549,12 +601,12 @@ export class ChangeExtractorService {
|
|||
this.openCodeBackfillCache.delete(cacheKey);
|
||||
}
|
||||
|
||||
if (result.diagnostics.length > 0 && result.outcome !== 'no-history') {
|
||||
if (diagnostics.length > 0 && result.outcome !== 'no-history') {
|
||||
logger.debug(
|
||||
`OpenCode ledger backfill for ${input.teamName}/${input.taskId}: ${result.outcome}; ${result.diagnostics.join('; ')}`
|
||||
`OpenCode ledger backfill for ${input.teamName}/${input.taskId}: ${result.outcome}; ${diagnostics.join('; ')}`
|
||||
);
|
||||
}
|
||||
return backfilled;
|
||||
return { attempted: true, backfilled };
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`OpenCode ledger backfill failed for ${input.teamName}/${input.taskId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
|
@ -565,11 +617,12 @@ export class ChangeExtractorService {
|
|||
teamName: input.teamName,
|
||||
taskId: input.taskId,
|
||||
displayId: input.taskMeta?.displayId ?? null,
|
||||
memberName: input.effectiveOptions.owner ?? null,
|
||||
memberName: backfillMemberName ?? input.effectiveOptions.owner ?? null,
|
||||
projectDir,
|
||||
workspaceRoot,
|
||||
deliveryRecordCount: deliveryContextRecords.length,
|
||||
deliveryContextFingerprint: this.hashOpenCodeDeliveryContextRecords(deliveryContextRecords),
|
||||
deliveryContextFingerprint: deliveryContextPayload.hash,
|
||||
evidencePipeline: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}).catch(() => undefined);
|
||||
if (deliveryContextRecords.length === 0) {
|
||||
|
|
@ -580,7 +633,7 @@ export class ChangeExtractorService {
|
|||
} else {
|
||||
this.openCodeBackfillCache.delete(cacheKey);
|
||||
}
|
||||
return false;
|
||||
return { attempted: true, backfilled: false };
|
||||
} finally {
|
||||
await deliveryContext.cleanup();
|
||||
}
|
||||
|
|
@ -647,36 +700,47 @@ export class ChangeExtractorService {
|
|||
private async createOpenCodeDeliveryContextTempFile(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
|
||||
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>,
|
||||
payload = this.buildOpenCodeDeliveryContextPayload(teamName, taskId, records)
|
||||
): Promise<OpenCodeDeliveryContextTempFile> {
|
||||
if (records.length === 0) {
|
||||
return { filePath: null, cleanup: async () => undefined };
|
||||
return { filePath: null, hash: null, cleanup: async () => undefined };
|
||||
}
|
||||
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), 'claude-team-opencode-ledger-context-'));
|
||||
await chmod(dir, 0o700).catch(() => undefined);
|
||||
const filePath = path.join(dir, 'delivery-context.json');
|
||||
await writeFile(
|
||||
filePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
teamName,
|
||||
taskId,
|
||||
records,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
{ encoding: 'utf8', mode: 0o600 }
|
||||
);
|
||||
await writeFile(filePath, payload.rawContext, { encoding: 'utf8', mode: 0o600 });
|
||||
return {
|
||||
filePath,
|
||||
hash: payload.hash,
|
||||
cleanup: async () => {
|
||||
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private buildOpenCodeDeliveryContextPayload(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
|
||||
): OpenCodeDeliveryContextPayload {
|
||||
const rawContext = `${JSON.stringify(
|
||||
{
|
||||
schemaVersion: 1,
|
||||
teamName,
|
||||
taskId,
|
||||
records,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`;
|
||||
return {
|
||||
hash: createHash('sha256').update(rawContext).digest('hex'),
|
||||
rawContext,
|
||||
};
|
||||
}
|
||||
|
||||
private async readOpenCodeDeliveryContextRecords(
|
||||
teamName: string,
|
||||
taskId: string
|
||||
|
|
@ -748,6 +812,18 @@ export class ChangeExtractorService {
|
|||
return records.slice(-200);
|
||||
}
|
||||
|
||||
private resolveOpenCodeBackfillMemberName(
|
||||
owner: string | undefined,
|
||||
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
|
||||
): string | undefined {
|
||||
const members = [...new Set(records.map((record) => record.memberName.trim()).filter(Boolean))];
|
||||
const normalizedOwner = owner?.trim();
|
||||
if (normalizedOwner && members.includes(normalizedOwner)) {
|
||||
return normalizedOwner;
|
||||
}
|
||||
return members.length === 1 ? members[0] : undefined;
|
||||
}
|
||||
|
||||
private async readOpenCodeRuntimeLaneIdsFromDisk(
|
||||
teamsBasePath: string,
|
||||
teamName: string
|
||||
|
|
@ -770,50 +846,6 @@ export class ChangeExtractorService {
|
|||
return laneIds.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
private hashOpenCodeDeliveryContextRecords(
|
||||
records: Awaited<ReturnType<ChangeExtractorService['readOpenCodeDeliveryContextRecords']>>
|
||||
): string {
|
||||
const stableRecords = records
|
||||
.map((record) => ({
|
||||
memberName: record.memberName,
|
||||
laneId: record.laneId ?? '',
|
||||
runtimeSessionId: record.runtimeSessionId ?? '',
|
||||
inboxMessageId: record.inboxMessageId ?? '',
|
||||
deliveredUserMessageId: record.deliveredUserMessageId ?? '',
|
||||
taskRefs: record.taskRefs
|
||||
.map((taskRef) => ({
|
||||
taskId: taskRef.taskId,
|
||||
displayId: taskRef.displayId,
|
||||
teamName: taskRef.teamName,
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
`${left.teamName}\0${left.taskId}\0${left.displayId}`.localeCompare(
|
||||
`${right.teamName}\0${right.taskId}\0${right.displayId}`
|
||||
)
|
||||
),
|
||||
}))
|
||||
.sort((left, right) =>
|
||||
[
|
||||
left.laneId,
|
||||
left.memberName,
|
||||
left.runtimeSessionId,
|
||||
left.inboxMessageId,
|
||||
left.deliveredUserMessageId,
|
||||
]
|
||||
.join('\0')
|
||||
.localeCompare(
|
||||
[
|
||||
right.laneId,
|
||||
right.memberName,
|
||||
right.runtimeSessionId,
|
||||
right.inboxMessageId,
|
||||
right.deliveredUserMessageId,
|
||||
].join('\0')
|
||||
)
|
||||
);
|
||||
return createHash('sha256').update(JSON.stringify(stableRecords)).digest('hex');
|
||||
}
|
||||
|
||||
private async readOpenCodePromptDeliveryLedgerRecords(
|
||||
filePath: string
|
||||
): Promise<OpenCodePromptDeliveryLedgerRecord[]> {
|
||||
|
|
@ -841,7 +873,7 @@ export class ChangeExtractorService {
|
|||
sourceGeneration?: string | null;
|
||||
deliveryContextFingerprint: string;
|
||||
attributionMode: typeof OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE;
|
||||
evidenceMode: typeof OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE;
|
||||
evidencePipeline: typeof OPEN_CODE_AUTO_BACKFILL_EVIDENCE_PIPELINE;
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
teamName: input.teamName,
|
||||
|
|
@ -852,7 +884,7 @@ export class ChangeExtractorService {
|
|||
sourceGeneration: input.sourceGeneration ?? '',
|
||||
deliveryContextFingerprint: input.deliveryContextFingerprint,
|
||||
attributionMode: input.attributionMode,
|
||||
evidenceMode: input.evidenceMode,
|
||||
evidencePipeline: input.evidencePipeline,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -293,6 +293,7 @@ export class ReviewApplierService {
|
|||
decision.fileDecision === 'rejected',
|
||||
allHunksRejected,
|
||||
rejectedHunkIndices,
|
||||
decision.hunkContextHashes,
|
||||
fileContent.snippets
|
||||
);
|
||||
if (ledgerOutcome.handled) {
|
||||
|
|
@ -450,6 +451,7 @@ export class ReviewApplierService {
|
|||
fileRejected: boolean,
|
||||
allHunksRejected: boolean,
|
||||
rejectedHunkIndices: number[],
|
||||
hunkContextHashes: Record<number, string> | undefined,
|
||||
snippets: SnippetDiff[]
|
||||
): Promise<LedgerApplyOutcome> {
|
||||
const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError);
|
||||
|
|
@ -497,6 +499,20 @@ export class ReviewApplierService {
|
|||
error: 'Ledger full text is unavailable; partial reject requires manual review.',
|
||||
};
|
||||
}
|
||||
const strictHunks = mapRejectedHunkIndicesByHashStrict(
|
||||
original,
|
||||
modified,
|
||||
rejectedHunkIndices,
|
||||
hunkContextHashes
|
||||
);
|
||||
if (!strictHunks.ok) {
|
||||
return {
|
||||
handled: true,
|
||||
status: strictHunks.code === 'conflict' ? 'conflict' : 'error',
|
||||
code: strictHunks.code,
|
||||
error: strictHunks.error,
|
||||
};
|
||||
}
|
||||
const guard = await this.checkLedgerCurrentHash(
|
||||
filePath,
|
||||
lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
|
||||
|
|
@ -504,7 +520,7 @@ export class ReviewApplierService {
|
|||
if (!guard.ok) {
|
||||
return guard.outcome;
|
||||
}
|
||||
const patchResult = this.tryHunkLevelReject(original, modified, rejectedHunkIndices);
|
||||
const patchResult = this.tryStrictHunkLevelReject(original, modified, strictHunks.indices);
|
||||
if (!patchResult) {
|
||||
return {
|
||||
handled: true,
|
||||
|
|
@ -1035,6 +1051,46 @@ export class ReviewApplierService {
|
|||
hadConflicts: false,
|
||||
};
|
||||
}
|
||||
|
||||
private tryStrictHunkLevelReject(
|
||||
original: string,
|
||||
modified: string,
|
||||
hunkIndices: number[]
|
||||
): RejectResult | null {
|
||||
const patch = structuredPatch('file', 'file', original, modified);
|
||||
|
||||
if (!patch.hunks || patch.hunks.length === 0) return null;
|
||||
|
||||
const validIndices = hunkIndices.filter((idx) => idx >= 0 && idx < patch.hunks.length);
|
||||
if (validIndices.length !== hunkIndices.length || validIndices.length === 0) return null;
|
||||
|
||||
const inversedHunks: StructuredPatchHunk[] = [];
|
||||
for (const idx of validIndices) {
|
||||
const hunk = patch.hunks[idx];
|
||||
if (!hunk) return null;
|
||||
inversedHunks.push(invertHunk(hunk));
|
||||
}
|
||||
|
||||
const inversePatch = {
|
||||
oldFileName: 'file',
|
||||
newFileName: 'file',
|
||||
oldHeader: undefined,
|
||||
newHeader: undefined,
|
||||
hunks: inversedHunks,
|
||||
};
|
||||
|
||||
const result = applyPatch(modified, inversePatch, { fuzzFactor: 0 });
|
||||
if (result === false) {
|
||||
logger.debug('Strict ledger hunk-level inverse patch не удался');
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newContent: result,
|
||||
hadConflicts: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildHunkHashIndexMap(original: string, modified: string): Map<string, number[]> {
|
||||
|
|
@ -1086,6 +1142,54 @@ function mapRejectedHunkIndicesByHash(
|
|||
return [...out].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
function mapRejectedHunkIndicesByHashStrict(
|
||||
original: string,
|
||||
modified: string,
|
||||
rejectedIndices: number[],
|
||||
hunkContextHashes: Record<number, string> | undefined
|
||||
): { ok: true; indices: number[] } | { ok: false; code: ApplyErrorCode; error: string } {
|
||||
if (rejectedIndices.length === 0) {
|
||||
return { ok: true, indices: [] };
|
||||
}
|
||||
if (!hunkContextHashes || Object.keys(hunkContextHashes).length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'manual-review-required',
|
||||
error: 'Ledger partial reject requires stable hunk context hashes.',
|
||||
};
|
||||
}
|
||||
|
||||
const hashMap = buildHunkHashIndexMap(original, modified);
|
||||
const out = new Set<number>();
|
||||
for (const idx of rejectedIndices) {
|
||||
const hash = hunkContextHashes[idx];
|
||||
if (!hash) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'manual-review-required',
|
||||
error: 'Ledger partial reject is missing a hunk context hash.',
|
||||
};
|
||||
}
|
||||
const candidates = hashMap.get(hash);
|
||||
if (!candidates || candidates.length === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'conflict',
|
||||
error: 'Ledger partial reject hunk context changed; please re-review.',
|
||||
};
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'conflict',
|
||||
error: 'Ledger partial reject hunk context is ambiguous; please re-review.',
|
||||
};
|
||||
}
|
||||
out.add(candidates[0]!);
|
||||
}
|
||||
return { ok: true, indices: [...out].sort((a, b) => a - b) };
|
||||
}
|
||||
|
||||
// ── Module-level helpers ──
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -129,6 +129,13 @@ interface LedgerEvent {
|
|||
linesRemoved?: number;
|
||||
replaceAll?: boolean;
|
||||
warnings?: string[];
|
||||
sourceRuntime?: 'opencode';
|
||||
sourceProvider?: 'opencode';
|
||||
sourceImportKey?: string;
|
||||
evidenceProof?: string;
|
||||
supersedesEventId?: string;
|
||||
snapshotId?: string;
|
||||
snapshotSource?: string;
|
||||
}
|
||||
|
||||
interface LedgerNotice {
|
||||
|
|
@ -196,7 +203,7 @@ interface LedgerSummaryScopeV2 {
|
|||
primaryAgentId?: string;
|
||||
primaryMemberName?: string;
|
||||
memberName: string;
|
||||
agentIds: string[];
|
||||
agentIds?: string[];
|
||||
memberNames?: string[];
|
||||
startTimestamp: string;
|
||||
endTimestamp: string;
|
||||
|
|
@ -428,11 +435,7 @@ export class TaskChangeLedgerReader {
|
|||
return null;
|
||||
}
|
||||
|
||||
const provenance = this.buildLedgerProvenance(
|
||||
bundle.journalStamp,
|
||||
bundle.integrity,
|
||||
bundle.schemaVersion
|
||||
);
|
||||
const provenance = this.buildLedgerProvenanceFromSummaryBundle(bundle);
|
||||
|
||||
if (
|
||||
freshness &&
|
||||
|
|
@ -450,11 +453,7 @@ export class TaskChangeLedgerReader {
|
|||
) {
|
||||
return {
|
||||
bundle,
|
||||
provenance: this.buildLedgerProvenance(
|
||||
journalStamp,
|
||||
bundle.integrity,
|
||||
bundle.schemaVersion
|
||||
),
|
||||
provenance: this.buildLedgerProvenanceFromSummaryBundle(bundle, journalStamp),
|
||||
mode: 'validated',
|
||||
};
|
||||
}
|
||||
|
|
@ -694,6 +693,86 @@ export class TaskChangeLedgerReader {
|
|||
return this.buildLedgerProvenance(journalStamp, integrity, bundleSchemaVersion);
|
||||
}
|
||||
|
||||
private buildLedgerProvenanceFromSummaryBundle(
|
||||
bundle: LedgerSummaryBundleV2,
|
||||
journalStamp: TaskChangeJournalStamp = bundle.journalStamp
|
||||
): TaskChangeProvenance {
|
||||
return {
|
||||
sourceKind: 'ledger',
|
||||
sourceFingerprint: this.hashFingerprintPayload(this.buildProjectedSummaryIdentity(bundle)),
|
||||
journalStamp,
|
||||
bundleSchemaVersion: bundle.schemaVersion,
|
||||
integrity: bundle.integrity,
|
||||
};
|
||||
}
|
||||
|
||||
private buildProjectedSummaryIdentity(bundle: LedgerSummaryBundleV2): unknown {
|
||||
return {
|
||||
kind: 'ledger-summary-v2-projected-identity',
|
||||
schemaVersion: bundle.schemaVersion,
|
||||
bundleKind: bundle.bundleKind,
|
||||
taskId: bundle.taskId,
|
||||
integrity: bundle.integrity,
|
||||
totalFiles: bundle.totalFiles,
|
||||
totalLinesAdded: bundle.totalLinesAdded,
|
||||
totalLinesRemoved: bundle.totalLinesRemoved,
|
||||
diffStatCompleteness: bundle.diffStatCompleteness,
|
||||
confidence: bundle.confidence,
|
||||
files: [...bundle.files]
|
||||
.map((file) => ({
|
||||
changeKey: this.normalizeSummaryChangeKey(file),
|
||||
filePath: normalizePathForComparison(file.filePath),
|
||||
relativePath: normalizePathForComparison(file.relativePath),
|
||||
displayPath: file.displayPath ? normalizePathForComparison(file.displayPath) : undefined,
|
||||
linesAdded: file.linesAdded,
|
||||
linesRemoved: file.linesRemoved,
|
||||
diffStatKnown: file.diffStatKnown,
|
||||
latestOperation: file.latestOperation,
|
||||
createdInTask: file.createdInTask,
|
||||
deletedInTask: file.deletedInTask,
|
||||
baselineExists: file.baselineExists,
|
||||
finalExists: file.finalExists,
|
||||
latestBeforeHash: file.latestBeforeHash,
|
||||
latestAfterHash: file.latestAfterHash,
|
||||
latestBeforeState: this.contentStateFingerprint(file.latestBeforeState),
|
||||
latestAfterState: this.contentStateFingerprint(file.latestAfterState),
|
||||
contentAvailability: file.contentAvailability,
|
||||
reviewability: file.reviewability,
|
||||
relation: file.relation
|
||||
? {
|
||||
kind: file.relation.kind,
|
||||
oldPath: normalizePathForComparison(file.relation.oldPath),
|
||||
newPath: normalizePathForComparison(file.relation.newPath),
|
||||
}
|
||||
: undefined,
|
||||
worktreePath: file.worktreePath
|
||||
? normalizePathForComparison(file.worktreePath)
|
||||
: undefined,
|
||||
worktreeBranch: file.worktreeBranch,
|
||||
baseWorkspaceRoot: file.baseWorkspaceRoot
|
||||
? normalizePathForComparison(file.baseWorkspaceRoot)
|
||||
: undefined,
|
||||
}))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.changeKey.localeCompare(right.changeKey) ||
|
||||
left.filePath.localeCompare(right.filePath)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private contentStateFingerprint(state: LedgerContentState | undefined): unknown {
|
||||
if (!state) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
exists: state.exists,
|
||||
sha256: state.sha256,
|
||||
sizeBytes: state.sizeBytes,
|
||||
unavailableReason: state.unavailableReason,
|
||||
};
|
||||
}
|
||||
|
||||
private hashFingerprintPayload(payload: unknown): string {
|
||||
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||||
}
|
||||
|
|
@ -793,9 +872,10 @@ export class TaskChangeLedgerReader {
|
|||
bundle?: LedgerSummaryBundleV2;
|
||||
provenance: TaskChangeProvenance;
|
||||
}): Promise<TaskChangeSetV2> {
|
||||
const snippets = await this.buildSnippets(params.projectDir, params.journal.events);
|
||||
const projectedEvents = this.projectJournalEventsForUi(params.journal.events);
|
||||
const snippets = await this.buildSnippets(params.projectDir, projectedEvents);
|
||||
const groupedSnippets = this.groupSnippets(snippets);
|
||||
const warnings = this.collectWarnings(params.journal.events, params.journal.notices, {
|
||||
const warnings = this.collectWarnings(projectedEvents, params.journal.notices, {
|
||||
recovered: params.journal.recovered,
|
||||
});
|
||||
|
||||
|
|
@ -836,15 +916,15 @@ export class TaskChangeLedgerReader {
|
|||
totalLinesAdded = fallback.totalLinesAdded;
|
||||
totalLinesRemoved = fallback.totalLinesRemoved;
|
||||
totalFiles = fallback.files.length;
|
||||
confidence = params.journal.events.some((event) => event.confidence === 'low')
|
||||
confidence = projectedEvents.some((event) => event.confidence === 'low')
|
||||
? 'low'
|
||||
: params.journal.events.some((event) => event.confidence === 'medium')
|
||||
: projectedEvents.some((event) => event.confidence === 'medium')
|
||||
? 'medium'
|
||||
: 'high';
|
||||
scope = this.buildFallbackScope(
|
||||
params.taskId,
|
||||
files,
|
||||
params.journal.events,
|
||||
projectedEvents,
|
||||
params.journal.notices
|
||||
);
|
||||
diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
|
||||
|
|
@ -883,7 +963,8 @@ export class TaskChangeLedgerReader {
|
|||
undefined,
|
||||
params.journal.recovered ? 'recovered' : 'ok'
|
||||
);
|
||||
const snippets = params.journal.events.map((event) => this.eventToSnippet(event, null, null));
|
||||
const projectedEvents = this.projectJournalEventsForUi(params.journal.events);
|
||||
const snippets = projectedEvents.map((event) => this.eventToSnippet(event, null, null));
|
||||
const grouped = this.groupSnippets(snippets);
|
||||
const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath);
|
||||
return {
|
||||
|
|
@ -893,20 +974,20 @@ export class TaskChangeLedgerReader {
|
|||
totalLinesAdded: fallback.totalLinesAdded,
|
||||
totalLinesRemoved: fallback.totalLinesRemoved,
|
||||
totalFiles: fallback.files.length,
|
||||
confidence: params.journal.events.some((event) => event.confidence === 'low')
|
||||
confidence: projectedEvents.some((event) => event.confidence === 'low')
|
||||
? 'low'
|
||||
: params.journal.events.some((event) => event.confidence === 'medium')
|
||||
: projectedEvents.some((event) => event.confidence === 'medium')
|
||||
? 'medium'
|
||||
: 'high',
|
||||
computedAt: new Date().toISOString(),
|
||||
scope: this.buildFallbackScope(
|
||||
params.taskId,
|
||||
fallback.files,
|
||||
params.journal.events,
|
||||
projectedEvents,
|
||||
params.journal.notices
|
||||
),
|
||||
warnings: [
|
||||
...this.collectWarnings(params.journal.events, params.journal.notices, {
|
||||
...this.collectWarnings(projectedEvents, params.journal.notices, {
|
||||
recovered: params.journal.recovered,
|
||||
}),
|
||||
'Task change summary fell back to journal reconstruction.',
|
||||
|
|
@ -972,6 +1053,7 @@ export class TaskChangeLedgerReader {
|
|||
private mapV2SummaryFile(file: LedgerSummaryFileV2, projectPath?: string): FileChangeSummary {
|
||||
const displayPath = file.displayPath ?? file.filePath;
|
||||
const filePath = this.normalizeLedgerFilePath(file.filePath);
|
||||
const agentIds = Array.isArray(file.agentIds) ? file.agentIds : [];
|
||||
return {
|
||||
filePath,
|
||||
relativePath: this.relativePath(displayPath, projectPath, file.relativePath),
|
||||
|
|
@ -993,7 +1075,7 @@ export class TaskChangeLedgerReader {
|
|||
...(file.latestBeforeState ? { beforeState: file.latestBeforeState } : {}),
|
||||
...(file.latestAfterState ? { afterState: file.latestAfterState } : {}),
|
||||
...(file.primaryActorKey ? { primaryActorKey: file.primaryActorKey } : {}),
|
||||
...(file.agentIds.length > 0 ? { agentIds: file.agentIds } : {}),
|
||||
...(agentIds.length > 0 ? { agentIds } : {}),
|
||||
...(file.memberNames ? { memberNames: file.memberNames } : {}),
|
||||
...(file.executionSeqRange ? { executionSeqRange: file.executionSeqRange } : {}),
|
||||
...(file.worktreePath ? { worktreePath: file.worktreePath } : {}),
|
||||
|
|
@ -1021,6 +1103,7 @@ export class TaskChangeLedgerReader {
|
|||
scope: LedgerSummaryScopeV2,
|
||||
files: LedgerSummaryFileV2[]
|
||||
): TaskChangeScope {
|
||||
const agentIds = Array.isArray(scope.agentIds) ? scope.agentIds : [];
|
||||
return {
|
||||
taskId,
|
||||
memberName:
|
||||
|
|
@ -1039,7 +1122,7 @@ export class TaskChangeLedgerReader {
|
|||
...(scope.primaryActorKey ? { primaryActorKey: scope.primaryActorKey } : {}),
|
||||
...(scope.primaryAgentId ? { primaryAgentId: scope.primaryAgentId } : {}),
|
||||
...(scope.primaryMemberName ? { primaryMemberName: scope.primaryMemberName } : {}),
|
||||
...(scope.agentIds.length > 0 ? { agentIds: scope.agentIds } : {}),
|
||||
...(agentIds.length > 0 ? { agentIds } : {}),
|
||||
...(scope.memberNames ? { memberNames: scope.memberNames } : {}),
|
||||
...(scope.toolUseCount !== undefined ? { toolUseCount: scope.toolUseCount } : {}),
|
||||
...(scope.toolUseIdsTruncated ? { toolUseIdsTruncated: true } : {}),
|
||||
|
|
@ -1064,6 +1147,75 @@ export class TaskChangeLedgerReader {
|
|||
);
|
||||
}
|
||||
|
||||
private projectJournalEventsForUi(events: LedgerEvent[]): LedgerEvent[] {
|
||||
const selectedBySourceImportKey = new Map<
|
||||
string,
|
||||
{ event: LedgerEvent; index: number; rank: number }
|
||||
>();
|
||||
const passthrough: Array<{ event: LedgerEvent; index: number }> = [];
|
||||
|
||||
events.forEach((event, index) => {
|
||||
const sourceImportKey = this.sourceImportKeyForEvent(event);
|
||||
if (!sourceImportKey) {
|
||||
passthrough.push({ event, index });
|
||||
return;
|
||||
}
|
||||
const rank = this.evidenceRankForEvent(event);
|
||||
const existing = selectedBySourceImportKey.get(sourceImportKey);
|
||||
if (!existing || rank >= existing.rank) {
|
||||
selectedBySourceImportKey.set(sourceImportKey, { event, index, rank });
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
...passthrough,
|
||||
...[...selectedBySourceImportKey.values()].map(({ event, index }) => ({ event, index })),
|
||||
]
|
||||
.sort((left, right) => left.index - right.index)
|
||||
.map(({ event }) => event);
|
||||
}
|
||||
|
||||
private sourceImportKeyForEvent(event: LedgerEvent): string | null {
|
||||
if (
|
||||
event.sourceImportKey &&
|
||||
(event.sourceRuntime === 'opencode' ||
|
||||
event.sourceProvider === 'opencode' ||
|
||||
event.source === 'opencode_toolpart_write' ||
|
||||
event.source === 'opencode_toolpart_edit' ||
|
||||
event.source === 'opencode_toolpart_apply_patch')
|
||||
) {
|
||||
return event.sourceImportKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private evidenceRankForEvent(event: LedgerEvent): number {
|
||||
const hasFullText = this.hasFullTextEvidence(event);
|
||||
|
||||
switch (event.evidenceProof) {
|
||||
case 'opencode-snapshot':
|
||||
return hasFullText ? 50 : 35;
|
||||
case 'inverse-apply-patch-chain':
|
||||
case 'inverse-edit-chain':
|
||||
case 'toolpart-chain':
|
||||
return hasFullText ? 40 : 25;
|
||||
case 'metadata-only-fallback':
|
||||
return 10;
|
||||
default:
|
||||
return hasFullText ? 30 : 5;
|
||||
}
|
||||
}
|
||||
|
||||
private hasFullTextEvidence(event: Pick<LedgerEvent, 'before' | 'after' | 'operation'>): boolean {
|
||||
if (event.operation === 'create') {
|
||||
return event.after !== null;
|
||||
}
|
||||
if (event.operation === 'delete') {
|
||||
return event.before !== null;
|
||||
}
|
||||
return event.before !== null && event.after !== null;
|
||||
}
|
||||
|
||||
private async readContentRef(
|
||||
projectDir: string,
|
||||
ref: LedgerContentRef | null
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ import type {
|
|||
TaskRef,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamMember,
|
||||
TeamMemberActivityMeta,
|
||||
TeamMemberSnapshot,
|
||||
|
|
@ -854,6 +855,50 @@ export class TeamDataService {
|
|||
return this.configReader.listTeams();
|
||||
}
|
||||
|
||||
async getSavedRequest(teamName: string): Promise<TeamCreateRequest | null> {
|
||||
const meta = await this.teamMetaStore.getMeta(teamName);
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const membersMeta = await this.membersMetaStore.getMeta(teamName);
|
||||
const members = membersMeta?.members ?? [];
|
||||
const resolvedProviderId = meta.providerId ?? 'anthropic';
|
||||
|
||||
return {
|
||||
teamName,
|
||||
displayName: meta.displayName,
|
||||
description: meta.description,
|
||||
color: meta.color,
|
||||
cwd: meta.cwd,
|
||||
prompt: meta.prompt,
|
||||
providerId: resolvedProviderId,
|
||||
providerBackendId: migrateProviderBackendId(
|
||||
resolvedProviderId,
|
||||
meta.providerBackendId ?? membersMeta?.providerBackendId
|
||||
),
|
||||
model: meta.model,
|
||||
effort: meta.effort as TeamCreateRequest['effort'],
|
||||
fastMode: meta.fastMode,
|
||||
skipPermissions: meta.skipPermissions,
|
||||
worktree: meta.worktree,
|
||||
extraCliArgs: meta.extraCliArgs,
|
||||
limitContext: meta.limitContext,
|
||||
members: members.map((member) => ({
|
||||
name: member.name,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
isolation: member.isolation,
|
||||
cwd: member.cwd,
|
||||
providerId: member.providerId,
|
||||
providerBackendId: member.providerBackendId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
fastMode: member.fastMode,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async listAliveProcessTeams(): Promise<string[]> {
|
||||
const teams = await this.listTeams();
|
||||
const alive: string[] = [];
|
||||
|
|
@ -2792,8 +2837,16 @@ export class TeamDataService {
|
|||
description: request.description,
|
||||
color: request.color,
|
||||
cwd: request.cwd?.trim() || '',
|
||||
prompt: request.prompt,
|
||||
providerId: request.providerId,
|
||||
providerBackendId: request.providerBackendId,
|
||||
model: request.model,
|
||||
effort: request.effort,
|
||||
fastMode: request.fastMode,
|
||||
skipPermissions: request.skipPermissions,
|
||||
worktree: request.worktree,
|
||||
extraCliArgs: request.extraCliArgs,
|
||||
limitContext: request.limitContext,
|
||||
createdAt: joinedAt,
|
||||
});
|
||||
|
||||
|
|
@ -2823,8 +2876,10 @@ export class TeamDataService {
|
|||
workflow: member.workflow?.trim() || undefined,
|
||||
isolation: member.isolation === 'worktree' ? ('worktree' as const) : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
providerBackendId: member.providerBackendId,
|
||||
model: member.model?.trim() || undefined,
|
||||
effort: isTeamEffortLevel(member.effort) ? member.effort : undefined,
|
||||
fastMode: member.fastMode,
|
||||
agentType: 'general-purpose' as const,
|
||||
joinedAt,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -74,6 +74,10 @@ function resolveWorkerPath(): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
function shouldWarnUnavailableWorker(): boolean {
|
||||
return process.env.NODE_ENV !== 'test' && process.env.VITEST !== 'true';
|
||||
}
|
||||
|
||||
export class TeamFsWorkerClient {
|
||||
private worker: Worker | null = null;
|
||||
private readonly workerPath: string | null = resolveWorkerPath();
|
||||
|
|
@ -84,7 +88,7 @@ export class TeamFsWorkerClient {
|
|||
>();
|
||||
|
||||
isAvailable(): boolean {
|
||||
if (!this.workerPath && !this.warnedUnavailable) {
|
||||
if (!this.workerPath && !this.warnedUnavailable && shouldWarnUnavailableWorker()) {
|
||||
this.warnedUnavailable = true;
|
||||
const baseDir =
|
||||
typeof __dirname === 'string' && __dirname.length > 0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { getMcpConfigsBasePath, getMcpServerBasePath } from '@main/utils/pathDecoder';
|
||||
import {
|
||||
getClaudeBasePath,
|
||||
getMcpConfigsBasePath,
|
||||
getMcpServerBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { execFile } from 'child_process';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
|
@ -13,6 +17,7 @@ export interface McpLaunchSpec {
|
|||
}
|
||||
|
||||
const MCP_SERVER_NAME = 'agent-teams';
|
||||
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
|
||||
const logger = createLogger('Service:TeamMcpConfigBuilder');
|
||||
const MCP_CONFIG_PREFIX = 'agent-teams-mcp-';
|
||||
const MCP_CONFIG_REMOVE_RETRY_DELAYS_MS = [25, 75, 150] as const;
|
||||
|
|
@ -273,6 +278,9 @@ export class TeamMcpConfigBuilder {
|
|||
[MCP_SERVER_NAME]: {
|
||||
command: launchSpec.command,
|
||||
args: launchSpec.args,
|
||||
env: {
|
||||
[MCP_CLAUDE_DIR_ENV]: getClaudeBasePath(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createHash } from 'crypto';
|
||||
|
||||
export const OPEN_CODE_BRIDGE_SCHEMA_VERSION = 1 as const;
|
||||
export const OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION = 1 as const;
|
||||
|
||||
export type OpenCodeBridgeCommandName =
|
||||
| 'opencode.handshake'
|
||||
|
|
@ -239,18 +240,12 @@ export interface OpenCodeBackfillTaskLedgerCommandBody {
|
|||
projectDir?: string;
|
||||
workspaceRoot?: string;
|
||||
deliveryContextPath?: string;
|
||||
deliveryContextHash?: string;
|
||||
attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode;
|
||||
evidenceMode?: OpenCodeBackfillTaskLedgerEvidenceMode;
|
||||
dryRun?: boolean;
|
||||
}
|
||||
|
||||
export type OpenCodeBackfillTaskLedgerAttributionMode = 'strict-delivery' | 'compatible';
|
||||
export type OpenCodeBackfillTaskLedgerEvidenceMode =
|
||||
| 'off'
|
||||
| 'metadata-only'
|
||||
| 'chain-only'
|
||||
| 'snapshot-probe'
|
||||
| 'snapshot-auto';
|
||||
|
||||
export type OpenCodeBackfillTaskLedgerOutcome =
|
||||
| 'imported'
|
||||
|
|
@ -265,13 +260,13 @@ export type OpenCodeBackfillTaskLedgerOutcome =
|
|||
export interface OpenCodeBackfillTaskLedgerCommandData {
|
||||
schemaVersion: 1;
|
||||
providerId: 'opencode';
|
||||
opencodeTaskLedgerEvidenceContractVersion?: number;
|
||||
teamName: string;
|
||||
taskId?: string;
|
||||
projectDir?: string;
|
||||
workspaceRoot?: string;
|
||||
dryRun: boolean;
|
||||
attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode;
|
||||
evidenceMode?: OpenCodeBackfillTaskLedgerEvidenceMode;
|
||||
strictWindowCandidateCount?: number;
|
||||
openCodeDbFingerprint?: string;
|
||||
deliveryLedgerFingerprint?: string;
|
||||
|
|
@ -369,6 +364,7 @@ export interface OpenCodeBridgePeerIdentity {
|
|||
minVersion: number;
|
||||
currentVersion: number;
|
||||
supportedCommands: OpenCodeBridgeCommandName[];
|
||||
opencodeTaskLedgerEvidenceContractVersion?: number;
|
||||
};
|
||||
runtime: {
|
||||
providerId: 'opencode';
|
||||
|
|
@ -853,7 +849,10 @@ function isPeerIdentity(value: unknown): value is OpenCodeBridgePeerIdentity {
|
|||
(bridgeProtocol.minVersion as number) < 1 ||
|
||||
(bridgeProtocol.currentVersion as number) < (bridgeProtocol.minVersion as number) ||
|
||||
!Array.isArray(bridgeProtocol.supportedCommands) ||
|
||||
!bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName)
|
||||
!bridgeProtocol.supportedCommands.every(isOpenCodeBridgeCommandName) ||
|
||||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion !== undefined &&
|
||||
(!Number.isInteger(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion) ||
|
||||
(bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion as number) < 1))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -308,7 +308,6 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}),
|
||||
dryRun: input.dryRun === true,
|
||||
...(input.attributionMode ? { attributionMode: input.attributionMode } : {}),
|
||||
...(input.evidenceMode ? { evidenceMode: input.evidenceMode } : {}),
|
||||
scannedSessions: 0,
|
||||
scannedToolparts: 0,
|
||||
candidateEvents: 0,
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ function resolveGeneratedBunLauncher(
|
|||
}
|
||||
|
||||
function resolveNpmNodeShim(content: string, launcherDir: string): DirectWindowsLauncher | null {
|
||||
const scriptMatch = /"%_prog%"\s+"([^"]+\.(?:cjs|mjs|js))"\s+%\*/i.exec(content);
|
||||
const scriptMatch = /"%_prog%"\s+"([^"]+(?:\.(?:cjs|mjs|js))?)"\s+%\*/i.exec(content);
|
||||
const scriptTemplate = scriptMatch?.[1];
|
||||
if (!scriptTemplate) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1611,8 +1611,16 @@ export const CreateTeamDialog = ({
|
|||
color: request.color,
|
||||
members: request.members,
|
||||
cwd: effectiveCwd || undefined,
|
||||
prompt: request.prompt,
|
||||
providerId: request.providerId,
|
||||
providerBackendId: request.providerBackendId,
|
||||
model: request.model,
|
||||
effort: request.effort,
|
||||
fastMode: request.fastMode,
|
||||
limitContext: request.limitContext,
|
||||
skipPermissions: request.skipPermissions,
|
||||
worktree: request.worktree,
|
||||
extraCliArgs: request.extraCliArgs,
|
||||
});
|
||||
onOpenTeam(request.teamName, effectiveCwd || undefined);
|
||||
resetFormState();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
import type {
|
||||
EffortLevel,
|
||||
TeamFastMode,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
} from '@shared/types';
|
||||
|
||||
export interface MemberDraft {
|
||||
id: string;
|
||||
|
|
@ -11,8 +16,10 @@ export interface MemberDraft {
|
|||
workflowChips?: InlineChip[];
|
||||
isolation?: 'worktree';
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
removedAt?: number | string | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,13 @@ import { normalizeOptionalTeamProviderId } from '@shared/utils/teamProvider';
|
|||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { EffortLevel, TeamProviderId, TeamProvisioningMemberInput } from '@shared/types';
|
||||
import type {
|
||||
EffortLevel,
|
||||
TeamFastMode,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
TeamProvisioningMemberInput,
|
||||
} from '@shared/types';
|
||||
|
||||
export function validateMemberNameInline(name: string): string | null {
|
||||
const trimmed = name.trim();
|
||||
|
|
@ -34,8 +40,10 @@ export function createMemberDraft(initial?: Partial<MemberDraft>): MemberDraft {
|
|||
workflow: initial?.workflow,
|
||||
isolation: initial?.isolation === 'worktree' ? 'worktree' : undefined,
|
||||
providerId,
|
||||
providerBackendId: initial?.providerBackendId,
|
||||
model: normalizeExplicitTeamModelForUi(providerId, initial?.model ?? ''),
|
||||
effort: initial?.effort,
|
||||
fastMode: initial?.fastMode,
|
||||
removedAt: initial?.removedAt,
|
||||
};
|
||||
}
|
||||
|
|
@ -47,8 +55,10 @@ export function createMemberDraftsFromInputs(
|
|||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
isolation?: 'worktree';
|
||||
removedAt?: number | string | null;
|
||||
}[]
|
||||
|
|
@ -67,8 +77,10 @@ export function createMemberDraftsFromInputs(
|
|||
workflow: member.workflow,
|
||||
isolation: member.isolation === 'worktree' ? 'worktree' : undefined,
|
||||
providerId: normalizeOptionalTeamProviderId(member.providerId),
|
||||
providerBackendId: member.providerBackendId,
|
||||
model: member.model ?? '',
|
||||
effort: normalizeDraftEffort(member.effort),
|
||||
fastMode: member.fastMode,
|
||||
removedAt: member.removedAt,
|
||||
});
|
||||
});
|
||||
|
|
@ -84,8 +96,10 @@ export function clearMemberModelOverrides(member: MemberDraft): MemberDraft {
|
|||
return {
|
||||
...member,
|
||||
providerId: undefined,
|
||||
providerBackendId: undefined,
|
||||
model: '',
|
||||
effort: undefined,
|
||||
fastMode: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +139,9 @@ export function normalizeMemberDraftForProviderMode(
|
|||
return {
|
||||
...member,
|
||||
providerId: normalizedProviderId,
|
||||
providerBackendId: undefined,
|
||||
model: '',
|
||||
fastMode: undefined,
|
||||
};
|
||||
}
|
||||
return member;
|
||||
|
|
@ -253,6 +269,9 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
|
|||
if (providerId) {
|
||||
result.providerId = providerId;
|
||||
}
|
||||
if (member.providerBackendId) {
|
||||
result.providerBackendId = member.providerBackendId;
|
||||
}
|
||||
const model = member.model?.trim();
|
||||
if (model) {
|
||||
result.model = normalizeExplicitTeamModelForUi(providerId, model);
|
||||
|
|
@ -261,6 +280,9 @@ export function buildMembersFromDrafts(members: MemberDraft[]): TeamProvisioning
|
|||
if (effort) {
|
||||
result.effort = effort;
|
||||
}
|
||||
if (member.fastMode) {
|
||||
result.fastMode = member.fastMode;
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.filter((member): member is NonNullable<typeof member> => member !== null);
|
||||
|
|
|
|||
|
|
@ -1283,8 +1283,20 @@ export interface TeamCreateConfigRequest {
|
|||
color?: string;
|
||||
members: TeamProvisioningMemberInput[];
|
||||
cwd?: string;
|
||||
prompt?: string;
|
||||
providerId?: TeamProviderId;
|
||||
providerBackendId?: TeamProviderBackendId;
|
||||
model?: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
/** When true, context window is limited to 200K tokens instead of the default. */
|
||||
limitContext?: boolean;
|
||||
/** When false, run WITHOUT --dangerously-skip-permissions (manual tool approval). Default: true. */
|
||||
skipPermissions?: boolean;
|
||||
/** Worktree name — CLI: --worktree <name>. */
|
||||
worktree?: string;
|
||||
/** Raw custom CLI args string, shell-split and appended to CLI command. */
|
||||
extraCliArgs?: string;
|
||||
}
|
||||
|
||||
export interface TeamCreateResponse {
|
||||
|
|
|
|||
3
src/types/agent-teams-controller.d.ts
vendored
3
src/types/agent-teams-controller.d.ts
vendored
|
|
@ -90,6 +90,9 @@ declare module 'agent-teams-controller' {
|
|||
}
|
||||
|
||||
export interface ControllerRuntimeApi {
|
||||
listTeams(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
getTeam(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
createTeam(flags: Record<string, unknown>): Promise<unknown>;
|
||||
launchTeam(flags: Record<string, unknown>): Promise<unknown>;
|
||||
stopTeam(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
getRuntimeState(flags?: Record<string, unknown>): Promise<unknown>;
|
||||
|
|
|
|||
15
test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/manifest.json
vendored
Normal file
15
test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/manifest.json
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"name": "opencode-snapshot-upgrade",
|
||||
"taskId": "fixture-opencode-snapshot-upgrade",
|
||||
"description": "OpenCode metadata-only import upgraded by source-driven snapshot evidence into one visible full-text row.",
|
||||
"projectRootToken": "__PROJECT_ROOT__",
|
||||
"expected": {
|
||||
"totalFiles": 1,
|
||||
"warnings": [],
|
||||
"relativePaths": [
|
||||
"src/snapshot-only.js"
|
||||
],
|
||||
"relationKinds": []
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-opencode-snapshot-upgrade","updatedAt":"2026-04-26T10:00:02.000Z","journalStamp":{"events":{"bytes":3265,"mtimeMs":1777197602000,"tailSha256":"fixture-opencode-snapshot-upgrade-tail"}},"eventCount":2,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const snapshot = 1;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const snapshot = 2;
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-opencode-snapshot-upgrade","generatedAt":"2026-04-26T10:00:02.000Z","journalStamp":{"events":{"bytes":3265,"mtimeMs":1777197602000,"tailSha256":"fixture-opencode-snapshot-upgrade-tail"}},"integrity":"ok","eventCount":2,"projectedEventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:bob","primaryMemberName":"bob","memberName":"bob","memberNames":["bob"],"startTimestamp":"2026-04-26T10:00:01.000Z","endTimestamp":"2026-04-26T10:00:01.000Z","toolUseIds":["bob-edit-snapshot-only"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":0,"end":0},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:bob","memberName":"bob","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":1,"firstTimestamp":"2026-04-26T10:00:01.000Z","lastTimestamp":"2026-04-26T10:00:01.000Z"}]},"files":[{"changeKey":"modify:__PROJECT_ROOT__/src/snapshot-only.js","filePath":"__PROJECT_ROOT__/src/snapshot-only.js","relativePath":"src/snapshot-only.js","linesAdded":1,"linesRemoved":1,"diffStatKnown":true,"eventCount":1,"journalEventCount":2,"firstTimestamp":"2026-04-26T10:00:01.000Z","lastTimestamp":"2026-04-26T10:00:01.000Z","latestOperation":"modify","createdInTask":false,"deletedInTask":false,"baselineExists":true,"finalExists":true,"latestBeforeHash":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","latestAfterHash":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","latestBeforeState":{"exists":true,"sha256":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","sizeBytes":27},"latestAfterState":{"exists":true,"sha256":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","sizeBytes":27},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:bob","memberNames":["bob"],"executionSeqRange":{"start":0,"end":0}}],"totalLinesAdded":1,"totalLinesRemoved":1,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{"schemaVersion":1,"taskId":"fixture-opencode-snapshot-upgrade","taskRef":"fixture-opencode-snapshot-upgrade","taskRefKind":"canonical","phase":"work","executionSeq":0,"sessionId":"opencode-session-fixture","memberName":"bob","toolUseId":"bob-edit-snapshot-only","source":"opencode_toolpart_edit","operation":"modify","confidence":"medium","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/snapshot-only.js","relativePath":"src/snapshot-only.js","timestamp":"2026-04-26T10:00:00.000Z","toolStatus":"succeeded","before":null,"after":null,"beforeState":{"exists":true,"unavailableReason":"opencode-before-content-unavailable"},"afterState":{"exists":true,"unavailableReason":"opencode-edit-final-content-unavailable"},"oldString":"snapshot = 1","newString":"snapshot = 2","linesAdded":0,"linesRemoved":0,"sourceRuntime":"opencode","sourceProvider":"opencode","sourceSessionId":"opencode-session-fixture","sourcePartId":"bob-edit-snapshot-only","sourceMessageId":"assistant-1","parentUserMessageId":"user-1","attributionMethod":"delivery-ledger-taskrefs","sourceImportKey":"opencode\u0000opencode-session-fixture\u0000bob-edit-snapshot-only\u0000src/snapshot-only.js","evidenceProof":"metadata-only-fallback","warnings":["OpenCode edit was captured without a git/snapshot baseline; apply/reject is manual-only."],"eventId":"opencode-metadata-only-event"}
|
||||
{"schemaVersion":1,"taskId":"fixture-opencode-snapshot-upgrade","taskRef":"fixture-opencode-snapshot-upgrade","taskRefKind":"canonical","phase":"work","executionSeq":0,"sessionId":"opencode-session-fixture","memberName":"bob","toolUseId":"bob-edit-snapshot-only","source":"opencode_toolpart_edit","operation":"modify","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/snapshot-only.js","relativePath":"src/snapshot-only.js","timestamp":"2026-04-26T10:00:01.000Z","toolStatus":"succeeded","before":{"sha256":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","sizeBytes":27,"blobRef":"sha256/402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6"},"after":{"sha256":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","sizeBytes":27,"blobRef":"sha256/892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887"},"beforeState":{"exists":true,"sha256":"402c3103f57599660a8b57bb741815adaa06f6d396f46739c279eec0fc25cfb6","sizeBytes":27},"afterState":{"exists":true,"sha256":"892dd6554b064c9dec7454fe77a71f364d012bad8e892b1ba9adaa30909fb887","sizeBytes":27},"oldString":"snapshot = 1","newString":"snapshot = 2","linesAdded":1,"linesRemoved":1,"sourceRuntime":"opencode","sourceProvider":"opencode","sourceSessionId":"opencode-session-fixture","sourcePartId":"bob-edit-snapshot-only","sourceMessageId":"assistant-1","parentUserMessageId":"user-1","attributionMethod":"delivery-ledger-taskrefs","sourceImportKey":"opencode\u0000opencode-session-fixture\u0000bob-edit-snapshot-only\u0000src/snapshot-only.js","evidenceProof":"inverse-edit-chain","snapshotId":"opencode-snapshot-window-fixture","snapshotSource":"opencode","supersedesEventId":"opencode-metadata-only-event","eventId":"opencode-snapshot-upgrade-event"}
|
||||
1
test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/src/snapshot-only.js
vendored
Normal file
1
test/fixtures/team/task-change-ledger/opencode-snapshot-upgrade/project/src/snapshot-only.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const snapshot = 2;
|
||||
395
test/main/http/teamMcpControl.integration.test.ts
Normal file
395
test/main/http/teamMcpControl.integration.test.ts
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import Fastify from 'fastify';
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
import type { AddressInfo } from 'net';
|
||||
|
||||
import { registerTools } from '../../../mcp-server/src/tools';
|
||||
import { registerTeamRoutes } from '@main/http/teams';
|
||||
import { TeamDataService } from '@main/services/team/TeamDataService';
|
||||
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||
import type { HttpServices } from '@main/http';
|
||||
import type {
|
||||
TeamCreateRequest,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamProvisioningProgress,
|
||||
TeamRuntimeState,
|
||||
} from '@shared/types/team';
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
execute: (args: Record<string, unknown>) => unknown;
|
||||
}
|
||||
|
||||
function collectTools(): Map<string, RegisteredTool> {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
|
||||
registerTools({
|
||||
addTool(config: RegisteredTool) {
|
||||
tools.set(config.name, config);
|
||||
},
|
||||
} as never);
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
function parseJsonToolResult(result: unknown): unknown {
|
||||
const text = (result as { content?: { text?: string }[] }).content?.[0]?.text;
|
||||
return JSON.parse(text ?? 'null');
|
||||
}
|
||||
|
||||
async function fetchJson(
|
||||
baseUrl: string,
|
||||
pathname: string
|
||||
): Promise<{
|
||||
body: unknown;
|
||||
status: number;
|
||||
}> {
|
||||
const response = await fetch(`${baseUrl}${pathname}`);
|
||||
return {
|
||||
status: response.status,
|
||||
body: await response.json(),
|
||||
};
|
||||
}
|
||||
|
||||
function createServices(claudeRoot: string): {
|
||||
createTeamCalls: TeamCreateRequest[];
|
||||
services: HttpServices;
|
||||
} {
|
||||
const teamDataService = new TeamDataService();
|
||||
const createTeamCalls: TeamCreateRequest[] = [];
|
||||
const aliveTeams = new Set<string>();
|
||||
const progressByRunId = new Map<string, TeamProvisioningProgress>();
|
||||
const runIdByTeam = new Map<string, string>();
|
||||
|
||||
async function persistLaunchedConfig(request: TeamCreateRequest): Promise<void> {
|
||||
const teamDir = path.join(claudeRoot, 'teams', request.teamName);
|
||||
await mkdir(teamDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: request.displayName ?? request.teamName,
|
||||
projectPath: request.cwd,
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
role: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
},
|
||||
...request.members.map((member) => ({
|
||||
name: member.name,
|
||||
role: member.role,
|
||||
workflow: member.workflow,
|
||||
agentType: 'teammate',
|
||||
providerId: member.providerId,
|
||||
providerBackendId: member.providerBackendId,
|
||||
model: member.model,
|
||||
effort: member.effort,
|
||||
fastMode: member.fastMode,
|
||||
})),
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
async function createTeam(
|
||||
request: TeamCreateRequest,
|
||||
onProgress: (progress: TeamProvisioningProgress) => void
|
||||
): Promise<TeamLaunchResponse> {
|
||||
createTeamCalls.push(request);
|
||||
await persistLaunchedConfig(request);
|
||||
|
||||
const runId = `run-${request.teamName}`;
|
||||
const progress: TeamProvisioningProgress = {
|
||||
runId,
|
||||
teamName: request.teamName,
|
||||
state: 'ready',
|
||||
message: 'Ready',
|
||||
startedAt: '2026-04-29T00:00:00.000Z',
|
||||
updatedAt: '2026-04-29T00:00:01.000Z',
|
||||
};
|
||||
aliveTeams.add(request.teamName);
|
||||
runIdByTeam.set(request.teamName, runId);
|
||||
progressByRunId.set(runId, progress);
|
||||
onProgress(progress);
|
||||
return { runId };
|
||||
}
|
||||
|
||||
const teamProvisioningService = {
|
||||
createTeam,
|
||||
launchTeam: async (
|
||||
request: TeamLaunchRequest,
|
||||
onProgress: (progress: TeamProvisioningProgress) => void
|
||||
): Promise<TeamLaunchResponse> => {
|
||||
return createTeam(
|
||||
{
|
||||
teamName: request.teamName,
|
||||
cwd: request.cwd,
|
||||
prompt: request.prompt,
|
||||
providerId: request.providerId,
|
||||
providerBackendId: request.providerBackendId,
|
||||
model: request.model,
|
||||
effort: request.effort,
|
||||
fastMode: request.fastMode,
|
||||
skipPermissions: request.skipPermissions,
|
||||
worktree: request.worktree,
|
||||
extraCliArgs: request.extraCliArgs,
|
||||
members: [],
|
||||
},
|
||||
onProgress
|
||||
);
|
||||
},
|
||||
getProvisioningStatus: (runId: string): Promise<TeamProvisioningProgress> => {
|
||||
const progress = progressByRunId.get(runId);
|
||||
if (!progress) {
|
||||
throw new Error('Unknown runId');
|
||||
}
|
||||
return Promise.resolve(progress);
|
||||
},
|
||||
getRuntimeState: (teamName: string): Promise<TeamRuntimeState> => {
|
||||
const runId = runIdByTeam.get(teamName) ?? null;
|
||||
return Promise.resolve({
|
||||
teamName,
|
||||
isAlive: aliveTeams.has(teamName),
|
||||
runId,
|
||||
progress: runId ? (progressByRunId.get(runId) ?? null) : null,
|
||||
});
|
||||
},
|
||||
stopTeam: (teamName: string): Promise<void> => {
|
||||
aliveTeams.delete(teamName);
|
||||
return Promise.resolve();
|
||||
},
|
||||
getAliveTeams: (): string[] => [...aliveTeams],
|
||||
} as HttpServices['teamProvisioningService'];
|
||||
|
||||
return {
|
||||
createTeamCalls,
|
||||
services: {
|
||||
projectScanner: {} as HttpServices['projectScanner'],
|
||||
sessionParser: {} as HttpServices['sessionParser'],
|
||||
subagentResolver: {} as HttpServices['subagentResolver'],
|
||||
chunkBuilder: {} as HttpServices['chunkBuilder'],
|
||||
dataCache: {} as HttpServices['dataCache'],
|
||||
updaterService: {} as HttpServices['updaterService'],
|
||||
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
|
||||
teamDataService,
|
||||
teamProvisioningService,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('MCP team tools over the local REST control API', () => {
|
||||
const tools = collectTools();
|
||||
|
||||
function getTool(name: string): RegisteredTool {
|
||||
const tool = tools.get(name);
|
||||
expect(tool).toBeDefined();
|
||||
return tool!;
|
||||
}
|
||||
|
||||
it('creates, gets, launches, and lists a team through MCP and REST end to end', async () => {
|
||||
const claudeRoot = await mkdtemp(path.join(tmpdir(), 'agent-teams-control-e2e-'));
|
||||
const projectDir = await mkdtemp(path.join(tmpdir(), 'agent-teams-project-e2e-'));
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
|
||||
const app = Fastify();
|
||||
const { createTeamCalls, services } = createServices(claudeRoot);
|
||||
registerTeamRoutes(app, services);
|
||||
|
||||
try {
|
||||
await app.listen({ host: '127.0.0.1', port: 0 });
|
||||
const address = app.server.address() as AddressInfo;
|
||||
const controlUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const created = parseJsonToolResult(
|
||||
await getTool('team_create').execute({
|
||||
claudeDir: claudeRoot,
|
||||
controlUrl,
|
||||
teamName: 'mcp-e2e-team',
|
||||
displayName: 'MCP E2E Team',
|
||||
description: 'Created by MCP integration test',
|
||||
color: '#3366ff',
|
||||
cwd: projectDir,
|
||||
prompt: 'Coordinate the test task',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
skipPermissions: false,
|
||||
worktree: 'feature-e2e',
|
||||
extraCliArgs: '--max-turns 5',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
role: 'Engineer',
|
||||
workflow: 'Ship a focused patch',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
})
|
||||
) as { teamName: string };
|
||||
expect(created).toEqual({ teamName: 'mcp-e2e-team' });
|
||||
|
||||
const restDraft = await fetchJson(controlUrl, '/api/teams/mcp-e2e-team');
|
||||
expect(restDraft.status).toBe(200);
|
||||
expect(restDraft.body).toMatchObject({
|
||||
teamName: 'mcp-e2e-team',
|
||||
pendingCreate: true,
|
||||
savedRequest: {
|
||||
teamName: 'mcp-e2e-team',
|
||||
displayName: 'MCP E2E Team',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
skipPermissions: false,
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const mcpDraft = parseJsonToolResult(
|
||||
await getTool('team_get').execute({
|
||||
claudeDir: claudeRoot,
|
||||
controlUrl,
|
||||
teamName: 'mcp-e2e-team',
|
||||
})
|
||||
);
|
||||
expect(mcpDraft).toMatchObject({
|
||||
teamName: 'mcp-e2e-team',
|
||||
pendingCreate: true,
|
||||
savedRequest: {
|
||||
prompt: 'Coordinate the test task',
|
||||
worktree: 'feature-e2e',
|
||||
extraCliArgs: '--max-turns 5',
|
||||
},
|
||||
});
|
||||
|
||||
const restListBeforeLaunch = await fetchJson(controlUrl, '/api/teams');
|
||||
expect(restListBeforeLaunch.status).toBe(200);
|
||||
expect(restListBeforeLaunch.body).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
teamName: 'mcp-e2e-team',
|
||||
displayName: 'MCP E2E Team',
|
||||
pendingCreate: true,
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const launched = parseJsonToolResult(
|
||||
await getTool('team_launch').execute({
|
||||
claudeDir: claudeRoot,
|
||||
controlUrl,
|
||||
teamName: 'mcp-e2e-team',
|
||||
cwd: projectDir,
|
||||
})
|
||||
) as { isAlive: boolean; progress: TeamProvisioningProgress; runId: string };
|
||||
expect(launched).toMatchObject({
|
||||
isAlive: true,
|
||||
runId: 'run-mcp-e2e-team',
|
||||
progress: {
|
||||
state: 'ready',
|
||||
teamName: 'mcp-e2e-team',
|
||||
},
|
||||
});
|
||||
expect(createTeamCalls).toHaveLength(1);
|
||||
expect(createTeamCalls[0]).toMatchObject({
|
||||
teamName: 'mcp-e2e-team',
|
||||
displayName: 'MCP E2E Team',
|
||||
cwd: projectDir,
|
||||
prompt: 'Coordinate the test task',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
skipPermissions: false,
|
||||
worktree: 'feature-e2e',
|
||||
extraCliArgs: '--max-turns 5',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
role: 'Engineer',
|
||||
workflow: 'Ship a focused patch',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const restRuntime = await fetchJson(controlUrl, '/api/teams/mcp-e2e-team/runtime');
|
||||
expect(restRuntime.status).toBe(200);
|
||||
expect(restRuntime.body).toMatchObject({
|
||||
teamName: 'mcp-e2e-team',
|
||||
isAlive: true,
|
||||
runId: 'run-mcp-e2e-team',
|
||||
});
|
||||
|
||||
const restListAfterLaunch = await fetchJson(controlUrl, '/api/teams');
|
||||
expect(restListAfterLaunch.status).toBe(200);
|
||||
const launchedListItem = (restListAfterLaunch.body as Record<string, unknown>[]).find(
|
||||
(team) => team.teamName === 'mcp-e2e-team'
|
||||
);
|
||||
expect(launchedListItem).toMatchObject({
|
||||
teamName: 'mcp-e2e-team',
|
||||
displayName: 'MCP E2E Team',
|
||||
});
|
||||
expect(launchedListItem).not.toHaveProperty('pendingCreate');
|
||||
|
||||
const mcpLaunchedTeam = parseJsonToolResult(
|
||||
await getTool('team_get').execute({
|
||||
claudeDir: claudeRoot,
|
||||
controlUrl,
|
||||
teamName: 'mcp-e2e-team',
|
||||
})
|
||||
);
|
||||
expect(mcpLaunchedTeam).toMatchObject({
|
||||
teamName: 'mcp-e2e-team',
|
||||
config: {
|
||||
name: 'MCP E2E Team',
|
||||
projectPath: projectDir,
|
||||
},
|
||||
members: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'builder',
|
||||
role: 'Engineer',
|
||||
}),
|
||||
]),
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
setClaudeBasePathOverride(null);
|
||||
await rm(claudeRoot, { recursive: true, force: true });
|
||||
await rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -4,22 +4,44 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
import { registerTeamRoutes } from '@main/http/teams';
|
||||
import type { HttpServices } from '@main/http';
|
||||
import type {
|
||||
TeamCreateConfigRequest,
|
||||
TeamCreateRequest,
|
||||
TeamLaunchRequest,
|
||||
TeamLaunchResponse,
|
||||
TeamProvisioningProgress,
|
||||
TeamRuntimeState,
|
||||
TeamSummary,
|
||||
TeamViewSnapshot,
|
||||
} from '@shared/types/team';
|
||||
|
||||
describe('HTTP team runtime routes', () => {
|
||||
function createServicesMock() {
|
||||
const launchTeam = vi.fn<
|
||||
(request: TeamLaunchRequest, onProgress: (progress: TeamProvisioningProgress) => void) => Promise<TeamLaunchResponse>
|
||||
>();
|
||||
const launchTeam =
|
||||
vi.fn<
|
||||
(
|
||||
request: TeamLaunchRequest,
|
||||
onProgress: (progress: TeamProvisioningProgress) => void
|
||||
) => Promise<TeamLaunchResponse>
|
||||
>();
|
||||
const getRuntimeState = vi.fn<(teamName: string) => Promise<TeamRuntimeState>>();
|
||||
const getProvisioningStatus = vi.fn<(runId: string) => Promise<TeamProvisioningProgress>>();
|
||||
const stopTeam = vi.fn<(teamName: string) => Promise<void>>(() => Promise.resolve());
|
||||
const getAliveTeams = vi.fn<() => string[]>();
|
||||
const createTeam =
|
||||
vi.fn<
|
||||
(
|
||||
request: TeamCreateRequest,
|
||||
onProgress: (progress: TeamProvisioningProgress) => void
|
||||
) => Promise<TeamLaunchResponse>
|
||||
>();
|
||||
const listTeams = vi.fn<() => Promise<TeamSummary[]>>();
|
||||
const getTeamData = vi.fn<(teamName: string) => Promise<TeamViewSnapshot>>();
|
||||
const getSavedRequest = vi.fn<(teamName: string) => Promise<TeamCreateRequest | null>>();
|
||||
const createTeamConfig = vi.fn<(request: TeamCreateConfigRequest) => Promise<void>>(() =>
|
||||
Promise.resolve()
|
||||
);
|
||||
const teamProvisioningService = {
|
||||
createTeam,
|
||||
launchTeam,
|
||||
getRuntimeState,
|
||||
getProvisioningStatus,
|
||||
|
|
@ -27,8 +49,22 @@ describe('HTTP team runtime routes', () => {
|
|||
getAliveTeams,
|
||||
} as Pick<
|
||||
NonNullable<HttpServices['teamProvisioningService']>,
|
||||
'launchTeam' | 'getRuntimeState' | 'getProvisioningStatus' | 'stopTeam' | 'getAliveTeams'
|
||||
| 'createTeam'
|
||||
| 'launchTeam'
|
||||
| 'getRuntimeState'
|
||||
| 'getProvisioningStatus'
|
||||
| 'stopTeam'
|
||||
| 'getAliveTeams'
|
||||
> as HttpServices['teamProvisioningService'];
|
||||
const teamDataService = {
|
||||
listTeams,
|
||||
getTeamData,
|
||||
getSavedRequest,
|
||||
createTeamConfig,
|
||||
} as Pick<
|
||||
NonNullable<HttpServices['teamDataService']>,
|
||||
'listTeams' | 'getTeamData' | 'getSavedRequest' | 'createTeamConfig'
|
||||
> as HttpServices['teamDataService'];
|
||||
|
||||
const services = {
|
||||
projectScanner: {} as HttpServices['projectScanner'],
|
||||
|
|
@ -38,6 +74,7 @@ describe('HTTP team runtime routes', () => {
|
|||
dataCache: {} as HttpServices['dataCache'],
|
||||
updaterService: {} as HttpServices['updaterService'],
|
||||
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
|
||||
teamDataService,
|
||||
teamProvisioningService,
|
||||
} satisfies HttpServices;
|
||||
|
||||
|
|
@ -48,6 +85,11 @@ describe('HTTP team runtime routes', () => {
|
|||
getProvisioningStatus,
|
||||
stopTeam,
|
||||
getAliveTeams,
|
||||
createTeam,
|
||||
listTeams,
|
||||
getTeamData,
|
||||
getSavedRequest,
|
||||
createTeamConfig,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +101,87 @@ describe('HTTP team runtime routes', () => {
|
|||
return { app, ...mocks };
|
||||
}
|
||||
|
||||
it('lists, gets, and creates draft teams through team data service', async () => {
|
||||
const { app, listTeams, getTeamData, createTeamConfig } = await createApp();
|
||||
listTeams.mockResolvedValue([
|
||||
{
|
||||
teamName: 'demo-team',
|
||||
displayName: 'Demo Team',
|
||||
description: 'Demo',
|
||||
memberCount: 1,
|
||||
taskCount: 0,
|
||||
lastActivity: null,
|
||||
pendingCreate: true,
|
||||
},
|
||||
]);
|
||||
getTeamData.mockResolvedValue({
|
||||
teamName: 'demo-team',
|
||||
config: null,
|
||||
tasks: [],
|
||||
messages: [],
|
||||
processes: [],
|
||||
kanban: null,
|
||||
} as unknown as TeamViewSnapshot);
|
||||
|
||||
try {
|
||||
const listResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams',
|
||||
});
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
expect(listResponse.json()[0]).toMatchObject({
|
||||
teamName: 'demo-team',
|
||||
pendingCreate: true,
|
||||
});
|
||||
|
||||
const getResponse = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams/demo-team',
|
||||
});
|
||||
expect(getResponse.statusCode).toBe(200);
|
||||
expect(getTeamData).toHaveBeenCalledWith('demo-team');
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams',
|
||||
payload: {
|
||||
teamName: 'new-team',
|
||||
displayName: 'New Team',
|
||||
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
},
|
||||
});
|
||||
expect(createResponse.statusCode).toBe(201);
|
||||
expect(createResponse.json()).toEqual({ teamName: 'new-team' });
|
||||
expect(createTeamConfig).toHaveBeenCalledWith({
|
||||
teamName: 'new-team',
|
||||
displayName: 'New Team',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
role: 'Engineer',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
},
|
||||
],
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('launches a team with validated request payload', async () => {
|
||||
const { app, launchTeam } = await createApp();
|
||||
launchTeam.mockResolvedValue({ runId: 'run-1' });
|
||||
|
|
@ -68,7 +191,7 @@ describe('HTTP team runtime routes', () => {
|
|||
method: 'POST',
|
||||
url: '/api/teams/demo-team/launch',
|
||||
payload: {
|
||||
cwd: '/tmp/project',
|
||||
cwd: '/Users/test/project',
|
||||
prompt: 'Resume work',
|
||||
skipPermissions: false,
|
||||
clearContext: true,
|
||||
|
|
@ -80,7 +203,7 @@ describe('HTTP team runtime routes', () => {
|
|||
expect(launchTeam).toHaveBeenCalledWith(
|
||||
{
|
||||
teamName: 'demo-team',
|
||||
cwd: '/tmp/project',
|
||||
cwd: '/Users/test/project',
|
||||
prompt: 'Resume work',
|
||||
providerId: 'anthropic',
|
||||
skipPermissions: false,
|
||||
|
|
@ -93,6 +216,97 @@ describe('HTTP team runtime routes', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('routes draft team launch through createTeam with saved metadata', async () => {
|
||||
const { app, createTeam, getSavedRequest, launchTeam } = await createApp();
|
||||
getSavedRequest.mockResolvedValue({
|
||||
teamName: 'draft-team',
|
||||
displayName: 'Draft Team',
|
||||
description: 'Saved draft',
|
||||
color: '#3366ff',
|
||||
cwd: '/Users/test/saved-project',
|
||||
prompt: 'Saved prompt',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
||||
});
|
||||
createTeam.mockResolvedValue({ runId: 'run-draft' });
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/teams/draft-team/launch',
|
||||
payload: {
|
||||
cwd: '/Users/test/project',
|
||||
effort: 'high',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ runId: 'run-draft' });
|
||||
expect(launchTeam).not.toHaveBeenCalled();
|
||||
expect(createTeam).toHaveBeenCalledWith(
|
||||
{
|
||||
teamName: 'draft-team',
|
||||
displayName: 'Draft Team',
|
||||
description: 'Saved draft',
|
||||
color: '#3366ff',
|
||||
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
||||
cwd: '/Users/test/project',
|
||||
prompt: 'Saved prompt',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
},
|
||||
expect.any(Function)
|
||||
);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns saved metadata for draft team get without requiring config.json', async () => {
|
||||
const { app, getSavedRequest, getTeamData } = await createApp();
|
||||
getSavedRequest.mockResolvedValue({
|
||||
teamName: 'draft-team',
|
||||
displayName: 'Draft Team',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams/draft-team',
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
teamName: 'draft-team',
|
||||
pendingCreate: true,
|
||||
savedRequest: {
|
||||
teamName: 'draft-team',
|
||||
displayName: 'Draft Team',
|
||||
cwd: '/Users/test/project',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
members: [{ name: 'builder', role: 'Engineer', providerId: 'codex' }],
|
||||
},
|
||||
});
|
||||
expect(getTeamData).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects launch requests with non-absolute cwd', async () => {
|
||||
const { app, launchTeam } = await createApp();
|
||||
|
||||
|
|
@ -114,7 +328,8 @@ describe('HTTP team runtime routes', () => {
|
|||
});
|
||||
|
||||
it('returns runtime state, provisioning status, and stop results', async () => {
|
||||
const { app, getRuntimeState, getProvisioningStatus, stopTeam, getAliveTeams } = await createApp();
|
||||
const { app, getRuntimeState, getProvisioningStatus, stopTeam, getAliveTeams } =
|
||||
await createApp();
|
||||
getRuntimeState
|
||||
.mockResolvedValueOnce({
|
||||
teamName: 'demo-team',
|
||||
|
|
@ -213,18 +428,15 @@ describe('HTTP team runtime routes', () => {
|
|||
|
||||
it('returns 501 when team runtime routes are registered without a runtime service', async () => {
|
||||
const app = Fastify();
|
||||
registerTeamRoutes(
|
||||
app,
|
||||
{
|
||||
projectScanner: {} as HttpServices['projectScanner'],
|
||||
sessionParser: {} as HttpServices['sessionParser'],
|
||||
subagentResolver: {} as HttpServices['subagentResolver'],
|
||||
chunkBuilder: {} as HttpServices['chunkBuilder'],
|
||||
dataCache: {} as HttpServices['dataCache'],
|
||||
updaterService: {} as HttpServices['updaterService'],
|
||||
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
|
||||
} satisfies HttpServices
|
||||
);
|
||||
registerTeamRoutes(app, {
|
||||
projectScanner: {} as HttpServices['projectScanner'],
|
||||
sessionParser: {} as HttpServices['sessionParser'],
|
||||
subagentResolver: {} as HttpServices['subagentResolver'],
|
||||
chunkBuilder: {} as HttpServices['chunkBuilder'],
|
||||
dataCache: {} as HttpServices['dataCache'],
|
||||
updaterService: {} as HttpServices['updaterService'],
|
||||
sshConnectionManager: {} as HttpServices['sshConnectionManager'],
|
||||
} satisfies HttpServices);
|
||||
await app.ready();
|
||||
|
||||
try {
|
||||
|
|
@ -234,7 +446,9 @@ describe('HTTP team runtime routes', () => {
|
|||
});
|
||||
|
||||
expect(response.statusCode).toBe(501);
|
||||
expect(response.json()).toEqual({ error: 'Team runtime control is not available in this mode' });
|
||||
expect(response.json()).toEqual({
|
||||
error: 'Team runtime control is not available in this mode',
|
||||
});
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import * as os from 'os';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||
import type {
|
||||
BoardTaskActivityDetailResult,
|
||||
BoardTaskActivityEntry,
|
||||
|
|
@ -30,7 +31,9 @@ vi.mock('@preload/constants/ipcChannels', async (importOriginal) => {
|
|||
|
||||
// Mock NotificationManager — handleShowMessageNotification calls addTeamNotification
|
||||
const { mockAddTeamNotification } = vi.hoisted(() => ({
|
||||
mockAddTeamNotification: vi.fn().mockResolvedValue({ id: 'n1', isRead: false, createdAt: Date.now() }),
|
||||
mockAddTeamNotification: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'n1', isRead: false, createdAt: Date.now() }),
|
||||
}));
|
||||
const { mockGetMembersMeta } = vi.hoisted(() => ({
|
||||
mockGetMembersMeta: vi.fn(),
|
||||
|
|
@ -147,25 +150,29 @@ describe('ipc teams handlers', () => {
|
|||
|
||||
const service = {
|
||||
listTeams: vi.fn(async () => [{ teamName: 'my-team', displayName: 'My Team' }]),
|
||||
getTeamData: vi.fn(async (): Promise<TeamViewSnapshot & { messages?: InboxMessage[] }> => ({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
})),
|
||||
getTeamData: vi.fn(
|
||||
async (): Promise<TeamViewSnapshot & { messages?: InboxMessage[] }> => ({
|
||||
teamName: 'my-team',
|
||||
config: { name: 'My Team' },
|
||||
tasks: [],
|
||||
members: [],
|
||||
kanbanState: { teamName: 'my-team', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
})
|
||||
),
|
||||
getMessageFeed: vi.fn(async () => ({
|
||||
teamName: 'my-team',
|
||||
feedRevision: 'rev-1',
|
||||
messages: [] as InboxMessage[],
|
||||
})),
|
||||
getMessagesPage: vi.fn(async (..._args: unknown[]): Promise<MessagesPage> => ({
|
||||
messages: [] as InboxMessage[],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-1',
|
||||
})),
|
||||
getMessagesPage: vi.fn(
|
||||
async (..._args: unknown[]): Promise<MessagesPage> => ({
|
||||
messages: [] as InboxMessage[],
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
feedRevision: 'rev-1',
|
||||
})
|
||||
),
|
||||
getMemberActivityMeta: vi.fn(async () => ({
|
||||
teamName: 'my-team',
|
||||
computedAt: '2026-03-12T10:00:00.000Z',
|
||||
|
|
@ -207,6 +214,7 @@ describe('ipc teams handlers', () => {
|
|||
removeTaskRelationship: vi.fn(async () => undefined),
|
||||
replaceMembers: vi.fn(async () => undefined),
|
||||
createTeamConfig: vi.fn(async () => undefined),
|
||||
getSavedRequest: vi.fn(async (): Promise<TeamCreateRequest | null> => null),
|
||||
};
|
||||
const provisioningService = {
|
||||
prepareForProvisioning: vi.fn(async () => ({
|
||||
|
|
@ -265,24 +273,26 @@ describe('ipc teams handlers', () => {
|
|||
getTaskActivity: vi.fn<() => Promise<BoardTaskActivityEntry[]>>(async () => []),
|
||||
};
|
||||
const boardTaskActivityDetailService = {
|
||||
getTaskActivityDetail:
|
||||
vi.fn<() => Promise<BoardTaskActivityDetailResult>>(async () => ({ status: 'missing' })),
|
||||
getTaskActivityDetail: vi.fn<() => Promise<BoardTaskActivityDetailResult>>(async () => ({
|
||||
status: 'missing',
|
||||
})),
|
||||
};
|
||||
const boardTaskLogStreamService = {
|
||||
getTaskLogStream:
|
||||
vi.fn<() => Promise<BoardTaskLogStreamResponse>>(async () => ({
|
||||
participants: [],
|
||||
defaultFilter: 'all',
|
||||
segments: [],
|
||||
})),
|
||||
getTaskLogStream: vi.fn<() => Promise<BoardTaskLogStreamResponse>>(async () => ({
|
||||
participants: [],
|
||||
defaultFilter: 'all',
|
||||
segments: [],
|
||||
})),
|
||||
};
|
||||
const boardTaskExactLogsService = {
|
||||
getTaskExactLogSummaries:
|
||||
vi.fn<() => Promise<BoardTaskExactLogSummariesResponse>>(async () => ({ items: [] })),
|
||||
getTaskExactLogSummaries: vi.fn<() => Promise<BoardTaskExactLogSummariesResponse>>(
|
||||
async () => ({ items: [] })
|
||||
),
|
||||
};
|
||||
const boardTaskExactLogDetailService = {
|
||||
getTaskExactLogDetail:
|
||||
vi.fn<() => Promise<BoardTaskExactLogDetailResult>>(async () => ({ status: 'missing' })),
|
||||
getTaskExactLogDetail: vi.fn<() => Promise<BoardTaskExactLogDetailResult>>(async () => ({
|
||||
status: 'missing',
|
||||
})),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -316,13 +326,14 @@ describe('ipc teams handlers', () => {
|
|||
boardTaskActivityDetailService as never,
|
||||
boardTaskLogStreamService as never,
|
||||
boardTaskExactLogsService as never,
|
||||
boardTaskExactLogDetailService as never,
|
||||
boardTaskExactLogDetailService as never
|
||||
);
|
||||
registerTeamHandlers(ipcMain as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
setClaudeBasePathOverride(null);
|
||||
});
|
||||
|
||||
it('registers all expected handlers', () => {
|
||||
|
|
@ -417,12 +428,7 @@ describe('ipc teams handlers', () => {
|
|||
|
||||
const taskId = 'task-js';
|
||||
const attachmentId = 'att-js';
|
||||
const attachmentDir = path.join(
|
||||
getAppDataPath(),
|
||||
'task-attachments',
|
||||
'my-team',
|
||||
taskId
|
||||
);
|
||||
const attachmentDir = path.join(getAppDataPath(), 'task-attachments', 'my-team', taskId);
|
||||
await fs.promises.rm(attachmentDir, { recursive: true, force: true });
|
||||
await fs.promises.mkdir(attachmentDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
|
|
@ -778,7 +784,9 @@ describe('ipc teams handlers', () => {
|
|||
);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining('FORBIDDEN: editing files, changing code, changing task/board state, delegating work, launching Agent/subagents'),
|
||||
expect.stringContaining(
|
||||
'FORBIDDEN: editing files, changing code, changing task/board state, delegating work, launching Agent/subagents'
|
||||
),
|
||||
undefined
|
||||
);
|
||||
expect(service.sendDirectToLead).toHaveBeenCalledWith(
|
||||
|
|
@ -814,7 +822,9 @@ describe('ipc teams handlers', () => {
|
|||
);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining('Persistent teammates currently configured: alice (reviewer), jack (developer)'),
|
||||
expect.stringContaining(
|
||||
'Persistent teammates currently configured: alice (reviewer), jack (developer)'
|
||||
),
|
||||
undefined
|
||||
);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
|
|
@ -842,7 +852,9 @@ describe('ipc teams handlers', () => {
|
|||
);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining('Make the acknowledgement at least 40 characters so it is preserved in the Messages panel.'),
|
||||
expect.stringContaining(
|
||||
'Make the acknowledgement at least 40 characters so it is preserved in the Messages panel.'
|
||||
),
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
|
@ -887,9 +899,10 @@ describe('ipc teams handlers', () => {
|
|||
'/COMPACT keep kanban',
|
||||
undefined
|
||||
);
|
||||
const compactCall = vi.mocked(provisioningService.sendMessageToTeam).mock
|
||||
.calls as unknown[][];
|
||||
expect(String(compactCall[0]?.[1] ?? '')).not.toContain('You received a direct message from the user');
|
||||
const compactCall = vi.mocked(provisioningService.sendMessageToTeam).mock.calls as unknown[][];
|
||||
expect(String(compactCall[0]?.[1] ?? '')).not.toContain(
|
||||
'You received a direct message from the user'
|
||||
);
|
||||
expect(String(compactCall[0]?.[1] ?? '')).not.toContain('Current durable team context:');
|
||||
expect(service.sendDirectToLead).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
|
|
@ -1933,7 +1946,9 @@ describe('ipc teams handlers', () => {
|
|||
);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
expect.stringContaining('Do NOT start work, claim tasks, or improvise workflow/task/process rules')
|
||||
expect.stringContaining(
|
||||
'Do NOT start work, claim tasks, or improvise workflow/task/process rules'
|
||||
)
|
||||
);
|
||||
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
|
|
@ -2516,7 +2531,9 @@ describe('ipc teams handlers', () => {
|
|||
})) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Live member migration between OpenCode and the primary runtime owner');
|
||||
expect(result.error).toContain(
|
||||
'Live member migration between OpenCode and the primary runtime owner'
|
||||
);
|
||||
expect(result.error).toContain('alice');
|
||||
expect(service.replaceMembers).not.toHaveBeenCalled();
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
|
|
@ -2562,7 +2579,9 @@ describe('ipc teams handlers', () => {
|
|||
})) as { success: boolean; error?: string };
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Live member migration between OpenCode and the primary runtime owner');
|
||||
expect(result.error).toContain(
|
||||
'Live member migration between OpenCode and the primary runtime owner'
|
||||
);
|
||||
expect(result.error).toContain('alice');
|
||||
expect(service.replaceMembers).not.toHaveBeenCalled();
|
||||
expect(provisioningService.reattachOpenCodeOwnedMemberLane).not.toHaveBeenCalled();
|
||||
|
|
@ -2843,6 +2862,42 @@ describe('ipc teams handlers', () => {
|
|||
expect(callArg.members).toEqual([]);
|
||||
});
|
||||
|
||||
it('createTeam preserves teammate backend and fast mode metadata', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'runtime-team',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
role: 'Engineer',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
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: 'Engineer',
|
||||
workflow: undefined,
|
||||
isolation: undefined,
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('handleCreateConfig accepts members: []', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
||||
const result = (await handler({} as never, {
|
||||
|
|
@ -2853,6 +2908,161 @@ describe('ipc teams handlers', () => {
|
|||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('handleCreateConfig preserves draft launch metadata', async () => {
|
||||
const handler = handlers.get(TEAM_CREATE_CONFIG)!;
|
||||
const result = (await handler({} as never, {
|
||||
teamName: 'draft-team',
|
||||
displayName: ' Draft Team ',
|
||||
description: ' Saved draft ',
|
||||
color: '#3366ff',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
role: ' Engineer ',
|
||||
workflow: ' Ship focused patches ',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: ' gpt-5.2 ',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
cwd: '/Users/test/project',
|
||||
prompt: ' Saved prompt ',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: ' gpt-5.2 ',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
skipPermissions: false,
|
||||
worktree: 'feature-x',
|
||||
extraCliArgs: '--max-turns 5',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(service.createTeamConfig).toHaveBeenCalledWith({
|
||||
teamName: 'draft-team',
|
||||
displayName: 'Draft Team',
|
||||
description: 'Saved draft',
|
||||
color: '#3366ff',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
role: 'Engineer',
|
||||
workflow: 'Ship focused patches',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
cwd: '/Users/test/project',
|
||||
prompt: 'Saved prompt',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
skipPermissions: false,
|
||||
worktree: 'feature-x',
|
||||
extraCliArgs: '--max-turns 5',
|
||||
});
|
||||
});
|
||||
|
||||
it('launches draft team through saved request without dropping Electron draft metadata', async () => {
|
||||
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ipc-draft-launch-'));
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
try {
|
||||
const teamDir = path.join(claudeRoot, 'teams', 'draft-team');
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(teamDir, 'team.meta.json'),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
displayName: 'Draft Team',
|
||||
cwd: '/Users/test/project',
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
);
|
||||
service.getSavedRequest.mockResolvedValueOnce({
|
||||
teamName: 'draft-team',
|
||||
displayName: 'Draft Team',
|
||||
description: 'Saved draft',
|
||||
color: '#3366ff',
|
||||
cwd: '/Users/test/project',
|
||||
prompt: 'Saved prompt',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'medium',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
skipPermissions: false,
|
||||
worktree: 'feature-x',
|
||||
extraCliArgs: '--max-turns 5',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
role: 'Engineer',
|
||||
workflow: 'Ship focused patches',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const handler = handlers.get(TEAM_LAUNCH)!;
|
||||
const result = (await handler({ sender: { send: vi.fn() } } as never, {
|
||||
teamName: 'draft-team',
|
||||
cwd: os.tmpdir(),
|
||||
effort: 'high',
|
||||
})) as { success: boolean };
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(provisioningService.launchTeam).not.toHaveBeenCalled();
|
||||
expect(provisioningService.createTeam).toHaveBeenCalledWith(
|
||||
{
|
||||
teamName: 'draft-team',
|
||||
displayName: 'Draft Team',
|
||||
description: 'Saved draft',
|
||||
color: '#3366ff',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
role: 'Engineer',
|
||||
workflow: 'Ship focused patches',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
cwd: os.tmpdir(),
|
||||
prompt: 'Saved prompt',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
skipPermissions: false,
|
||||
worktree: 'feature-x',
|
||||
extraCliArgs: '--max-turns 5',
|
||||
},
|
||||
expect.any(Function)
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(claudeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('handleReplaceMembers accepts members: []', async () => {
|
||||
const handler = handlers.get(TEAM_REPLACE_MEMBERS)!;
|
||||
const result = (await handler({} as never, 'my-team', {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
|
|||
let previousDisableRuntimeBootstrap: string | undefined;
|
||||
let previousHome: string | undefined;
|
||||
let previousUserProfile: string | undefined;
|
||||
let previousNodeEnv: string | undefined;
|
||||
let svc: TeamProvisioningService | null;
|
||||
let teamName: string | null;
|
||||
|
||||
|
|
@ -45,8 +46,10 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
|
|||
previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
previousHome = process.env.HOME;
|
||||
previousUserProfile = process.env.USERPROFILE;
|
||||
previousNodeEnv = process.env.NODE_ENV;
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
process.env.NODE_ENV = 'production';
|
||||
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';
|
||||
|
|
@ -67,7 +70,13 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
|
|||
restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap);
|
||||
restoreEnv('HOME', previousHome);
|
||||
restoreEnv('USERPROFILE', previousUserProfile);
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
restoreEnv('NODE_ENV', previousNodeEnv);
|
||||
if (process.env.ANTHROPIC_RUNTIME_MEMORY_LIVE_KEEP_TEMP === '1') {
|
||||
// Live-debug only: preserve process/runtime logs for failed Windows liveness triage.
|
||||
process.stderr.write(`Preserving Anthropic runtime memory live temp dir: ${tempDir}\n`);
|
||||
return;
|
||||
}
|
||||
await removeTempDirWithRetries(tempDir);
|
||||
});
|
||||
|
||||
it('creates a real Anthropic team and reports teammate RSS in the runtime snapshot', async () => {
|
||||
|
|
@ -79,6 +88,7 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
|
|||
teamName = `anthropic-memory-live-${Date.now()}`;
|
||||
const projectPath = path.join(tempDir, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeTrustedClaudeConfig(tempClaudeRoot, projectPath);
|
||||
await fs.writeFile(
|
||||
path.join(projectPath, 'README.md'),
|
||||
'# Anthropic runtime memory live e2e\n',
|
||||
|
|
@ -133,7 +143,7 @@ liveDescribe('Anthropic runtime memory live e2e', () => {
|
|||
typeof alice.rssBytes === 'number' &&
|
||||
alice.rssBytes > 0
|
||||
);
|
||||
}, 60_000);
|
||||
}, 180_000, 1_000, () => JSON.stringify(snapshot, null, 2));
|
||||
|
||||
expect(snapshot!.members.alice).toMatchObject({
|
||||
alive: true,
|
||||
|
|
@ -158,10 +168,53 @@ async function assertExecutable(filePath: string): Promise<void> {
|
|||
await fs.access(filePath, fsConstants.X_OK);
|
||||
}
|
||||
|
||||
async function writeTrustedClaudeConfig(configDir: string, projectPath: string): Promise<void> {
|
||||
const normalizedProjectPath = path.normalize(projectPath).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 removeTempDirWithRetries(dirPath: string): Promise<void> {
|
||||
const attempts = process.platform === 'win32' ? 20 : 1;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
await fs.rm(dirPath, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if ((code !== 'EBUSY' && code !== 'EPERM') || attempt === attempts) {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
pollMs = 1_000
|
||||
pollMs = 1_000,
|
||||
describeState?: () => string
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
|
|
@ -178,7 +231,8 @@ async function waitUntil(
|
|||
}
|
||||
const suffix =
|
||||
lastError instanceof Error && lastError.message ? ` Last error: ${lastError.message}` : '';
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}`);
|
||||
const state = describeState ? ` Last state: ${describeState()}` : '';
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}${state}`);
|
||||
}
|
||||
|
||||
function formatProgressDump(progressEvents: TeamProvisioningProgress[]): string {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
import { ChangeExtractorService } from '../../../../src/main/services/team/ChangeExtractorService';
|
||||
import { OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION } from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
import { buildTaskChangePresenceDescriptor } from '../../../../src/main/services/team/taskChangePresenceUtils';
|
||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
|
|
@ -79,6 +81,7 @@ async function writeOpenCodeDeliveryLedger(
|
|||
taskId: string;
|
||||
displayId: string;
|
||||
teamName: string;
|
||||
taskRefs: { taskId: string; displayId: string; teamName: string }[];
|
||||
}>
|
||||
): Promise<string> {
|
||||
const memberName = overrides?.memberName ?? 'bob';
|
||||
|
|
@ -108,7 +111,7 @@ async function writeOpenCodeDeliveryLedger(
|
|||
observedAssistantMessageId: overrides?.observedAssistantMessageId ?? null,
|
||||
prePromptCursor: null,
|
||||
postPromptCursor: null,
|
||||
taskRefs: [
|
||||
taskRefs: overrides?.taskRefs ?? [
|
||||
{
|
||||
taskId: overrides?.taskId ?? TASK_ID,
|
||||
displayId: overrides?.displayId ?? 'abc12345',
|
||||
|
|
@ -126,6 +129,70 @@ async function writeOpenCodeDeliveryLedger(
|
|||
return filePath;
|
||||
}
|
||||
|
||||
async function writeOpenCodeLedgerBundle(
|
||||
projectDir: string,
|
||||
projectPath: string,
|
||||
taskId: string = TASK_ID
|
||||
): Promise<void> {
|
||||
const bundleDir = path.join(projectDir, '.board-task-changes', 'bundles');
|
||||
await fs.mkdir(bundleDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(bundleDir, `${encodeURIComponent(taskId)}.json`),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
source: 'task-change-ledger',
|
||||
taskId,
|
||||
generatedAt: '2026-03-01T10:00:00.000Z',
|
||||
eventCount: 1,
|
||||
files: [
|
||||
{
|
||||
filePath: path.join(projectPath, 'src/opencode.ts'),
|
||||
relativePath: 'src/opencode.ts',
|
||||
eventIds: ['event-1'],
|
||||
linesAdded: 1,
|
||||
linesRemoved: 0,
|
||||
isNewFile: true,
|
||||
latestAfterHash: null,
|
||||
},
|
||||
],
|
||||
totalLinesAdded: 1,
|
||||
totalLinesRemoved: 0,
|
||||
totalFiles: 1,
|
||||
confidence: 'high',
|
||||
warnings: [],
|
||||
events: [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
eventId: 'event-1',
|
||||
taskId,
|
||||
taskRef: taskId,
|
||||
taskRefKind: 'canonical',
|
||||
phase: 'work',
|
||||
executionSeq: 0,
|
||||
sessionId: 'opencode-session-1',
|
||||
memberName: 'bob',
|
||||
toolUseId: 'part-1',
|
||||
source: 'opencode_toolpart_write',
|
||||
operation: 'create',
|
||||
confidence: 'exact',
|
||||
workspaceRoot: projectPath,
|
||||
filePath: path.join(projectPath, 'src/opencode.ts'),
|
||||
relativePath: 'src/opencode.ts',
|
||||
timestamp: '2026-03-01T10:00:00.000Z',
|
||||
toolStatus: 'succeeded',
|
||||
before: null,
|
||||
after: null,
|
||||
oldString: '',
|
||||
newString: 'export const source = "opencode";\n',
|
||||
linesAdded: 1,
|
||||
linesRemoved: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
function persistedEntryPath(baseDir: string): string {
|
||||
return path.join(baseDir, 'task-change-summaries', encodeURIComponent(TEAM_NAME), `${TASK_ID}.json`);
|
||||
}
|
||||
|
|
@ -934,64 +1001,13 @@ describe('ChangeExtractorService', () => {
|
|||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeOpenCodeDeliveryLedger(tmpDir);
|
||||
|
||||
let deliveryContextHashVerified = false;
|
||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
||||
const bundleDir = path.join(input.projectDir, '.board-task-changes', 'bundles');
|
||||
await fs.mkdir(bundleDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
source: 'task-change-ledger',
|
||||
taskId: TASK_ID,
|
||||
generatedAt: '2026-03-01T10:00:00.000Z',
|
||||
eventCount: 1,
|
||||
files: [
|
||||
{
|
||||
filePath: path.join(projectPath, 'src/opencode.ts'),
|
||||
relativePath: 'src/opencode.ts',
|
||||
eventIds: ['event-1'],
|
||||
linesAdded: 1,
|
||||
linesRemoved: 0,
|
||||
isNewFile: true,
|
||||
latestAfterHash: null,
|
||||
},
|
||||
],
|
||||
totalLinesAdded: 1,
|
||||
totalLinesRemoved: 0,
|
||||
totalFiles: 1,
|
||||
confidence: 'high',
|
||||
warnings: [],
|
||||
events: [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
eventId: 'event-1',
|
||||
taskId: TASK_ID,
|
||||
taskRef: TASK_ID,
|
||||
taskRefKind: 'canonical',
|
||||
phase: 'work',
|
||||
executionSeq: 0,
|
||||
sessionId: 'opencode-session-1',
|
||||
memberName: 'bob',
|
||||
toolUseId: 'part-1',
|
||||
source: 'opencode_toolpart_write',
|
||||
operation: 'create',
|
||||
confidence: 'exact',
|
||||
workspaceRoot: projectPath,
|
||||
filePath: path.join(projectPath, 'src/opencode.ts'),
|
||||
relativePath: 'src/opencode.ts',
|
||||
timestamp: '2026-03-01T10:00:00.000Z',
|
||||
toolStatus: 'succeeded',
|
||||
before: null,
|
||||
after: null,
|
||||
oldString: '',
|
||||
newString: 'export const source = "opencode";\n',
|
||||
linesAdded: 1,
|
||||
linesRemoved: 0,
|
||||
},
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
deliveryContextHashVerified =
|
||||
createHash('sha256')
|
||||
.update(await fs.readFile(input.deliveryContextPath, 'utf8'))
|
||||
.digest('hex') === input.deliveryContextHash;
|
||||
await writeOpenCodeLedgerBundle(input.projectDir, projectPath);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
|
|
@ -1058,12 +1074,339 @@ describe('ChangeExtractorService', () => {
|
|||
projectDir,
|
||||
workspaceRoot: projectPath,
|
||||
attributionMode: 'strict-delivery',
|
||||
evidenceMode: 'chain-only',
|
||||
})
|
||||
);
|
||||
const backfillInput = backfillOpenCodeTaskLedger.mock.calls[0]?.[0];
|
||||
expect(backfillInput.deliveryContextPath).toEqual(
|
||||
expect.stringContaining('delivery-context.json')
|
||||
);
|
||||
expect(backfillInput.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(deliveryContextHashVerified).toBe(true);
|
||||
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rereads ledger when OpenCode backfill writes artifacts and then fails', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
||||
const projectDir = path.join(tmpDir, 'project-dir');
|
||||
const projectPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeOpenCodeDeliveryLedger(tmpDir);
|
||||
|
||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
||||
await writeOpenCodeLedgerBundle(input.projectDir, projectPath);
|
||||
throw new Error('timeout after import');
|
||||
});
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
||||
),
|
||||
};
|
||||
|
||||
const service = new ChangeExtractorService(
|
||||
{
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir,
|
||||
projectPath,
|
||||
sessionIds: [],
|
||||
})),
|
||||
findLogFileRefsForTask: vi.fn(async () => []),
|
||||
findMemberLogPaths: vi.fn(async () => []),
|
||||
} as any,
|
||||
{
|
||||
parseBoundaries: vi.fn(async () => ({
|
||||
boundaries: [],
|
||||
scopes: [],
|
||||
isSingleTaskSession: true,
|
||||
detectedMechanism: 'none' as const,
|
||||
})),
|
||||
} as any,
|
||||
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
||||
undefined,
|
||||
workerClient as any,
|
||||
{ backfillOpenCodeTaskLedger } as any,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
||||
);
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(result.files).toHaveLength(1);
|
||||
expect(result.files[0]?.snippets[0]?.toolName).toBe('Write');
|
||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
||||
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the OpenCode delivery member when the current task owner changed later', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'alice' });
|
||||
const projectDir = path.join(tmpDir, 'project-dir');
|
||||
const projectPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeOpenCodeDeliveryLedger(tmpDir, { memberName: 'bob' });
|
||||
|
||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
teamName: input.teamName,
|
||||
taskId: input.taskId,
|
||||
projectDir: input.projectDir,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
dryRun: false,
|
||||
attributionMode: input.attributionMode,
|
||||
scannedSessions: 0,
|
||||
scannedToolparts: 0,
|
||||
candidateEvents: 0,
|
||||
importedEvents: 0,
|
||||
skippedEvents: 0,
|
||||
outcome: 'no-history',
|
||||
notices: [],
|
||||
diagnostics: [],
|
||||
}));
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
||||
),
|
||||
};
|
||||
|
||||
const service = new ChangeExtractorService(
|
||||
{
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir,
|
||||
projectPath,
|
||||
sessionIds: [],
|
||||
})),
|
||||
findLogFileRefsForTask: vi.fn(async () => []),
|
||||
findMemberLogPaths: vi.fn(async () => []),
|
||||
} as any,
|
||||
{
|
||||
parseBoundaries: vi.fn(async () => ({
|
||||
boundaries: [],
|
||||
scopes: [],
|
||||
isSingleTaskSession: true,
|
||||
detectedMechanism: 'none' as const,
|
||||
})),
|
||||
} as any,
|
||||
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
||||
undefined,
|
||||
workerClient as any,
|
||||
{ backfillOpenCodeTaskLedger } as any,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
||||
);
|
||||
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
memberName: 'bob',
|
||||
attributionMode: 'strict-delivery',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('omits member filter when multiple OpenCode delivery members match the task', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'alice' });
|
||||
const projectDir = path.join(tmpDir, 'project-dir');
|
||||
const projectPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeOpenCodeDeliveryLedger(tmpDir, { memberName: 'bob', runtimeSessionId: 'session-1' });
|
||||
await writeOpenCodeDeliveryLedger(tmpDir, {
|
||||
memberName: 'carol',
|
||||
runtimeSessionId: 'session-2',
|
||||
});
|
||||
|
||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
teamName: input.teamName,
|
||||
taskId: input.taskId,
|
||||
projectDir: input.projectDir,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
dryRun: false,
|
||||
attributionMode: input.attributionMode,
|
||||
scannedSessions: 0,
|
||||
scannedToolparts: 0,
|
||||
candidateEvents: 0,
|
||||
importedEvents: 0,
|
||||
skippedEvents: 0,
|
||||
outcome: 'no-history',
|
||||
notices: [],
|
||||
diagnostics: [],
|
||||
}));
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
||||
),
|
||||
};
|
||||
|
||||
const service = new ChangeExtractorService(
|
||||
{
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir,
|
||||
projectPath,
|
||||
sessionIds: [],
|
||||
})),
|
||||
findLogFileRefsForTask: vi.fn(async () => []),
|
||||
findMemberLogPaths: vi.fn(async () => []),
|
||||
} as any,
|
||||
{
|
||||
parseBoundaries: vi.fn(async () => ({
|
||||
boundaries: [],
|
||||
scopes: [],
|
||||
isSingleTaskSession: true,
|
||||
detectedMechanism: 'none' as const,
|
||||
})),
|
||||
} as any,
|
||||
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
||||
undefined,
|
||||
workerClient as any,
|
||||
{ backfillOpenCodeTaskLedger } as any,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
||||
);
|
||||
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'alice',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]).not.toHaveProperty('memberName');
|
||||
});
|
||||
|
||||
it('ignores OpenCode delivery records that match only a recreated task display id', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
||||
const projectDir = path.join(tmpDir, 'project-dir');
|
||||
const projectPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeOpenCodeDeliveryLedger(tmpDir, {
|
||||
taskId: 'old-task',
|
||||
displayId: 'abc12345',
|
||||
memberName: 'bob',
|
||||
});
|
||||
|
||||
const backfillOpenCodeTaskLedger = vi.fn(async () => {
|
||||
throw new Error('display-id-only delivery record must not backfill');
|
||||
});
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
||||
),
|
||||
};
|
||||
|
||||
const service = new ChangeExtractorService(
|
||||
{
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir,
|
||||
projectPath,
|
||||
sessionIds: [],
|
||||
})),
|
||||
findLogFileRefsForTask: vi.fn(async () => []),
|
||||
findMemberLogPaths: vi.fn(async () => []),
|
||||
} as any,
|
||||
{
|
||||
parseBoundaries: vi.fn(async () => ({
|
||||
boundaries: [],
|
||||
scopes: [],
|
||||
isSingleTaskSession: true,
|
||||
detectedMechanism: 'none' as const,
|
||||
})),
|
||||
} as any,
|
||||
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
||||
undefined,
|
||||
workerClient as any,
|
||||
{ backfillOpenCodeTaskLedger } as any,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
||||
);
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
|
||||
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('ignores OpenCode delivery records that only mention related tasks', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
||||
const projectDir = path.join(tmpDir, 'project-dir');
|
||||
const projectPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeOpenCodeDeliveryLedger(tmpDir, {
|
||||
taskId: 'related-task',
|
||||
displayId: 'def67890',
|
||||
memberName: 'bob',
|
||||
});
|
||||
|
||||
const backfillOpenCodeTaskLedger = vi.fn(async () => {
|
||||
throw new Error('related-only delivery record must not backfill');
|
||||
});
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
||||
),
|
||||
};
|
||||
|
||||
const service = new ChangeExtractorService(
|
||||
{
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir,
|
||||
projectPath,
|
||||
sessionIds: [],
|
||||
})),
|
||||
findLogFileRefsForTask: vi.fn(async () => []),
|
||||
findMemberLogPaths: vi.fn(async () => []),
|
||||
} as any,
|
||||
{
|
||||
parseBoundaries: vi.fn(async () => ({
|
||||
boundaries: [],
|
||||
scopes: [],
|
||||
isSingleTaskSession: true,
|
||||
detectedMechanism: 'none' as const,
|
||||
})),
|
||||
} as any,
|
||||
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
||||
undefined,
|
||||
workerClient as any,
|
||||
{ backfillOpenCodeTaskLedger } as any,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
||||
);
|
||||
|
||||
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(result.files).toHaveLength(0);
|
||||
expect(backfillOpenCodeTaskLedger).not.toHaveBeenCalled();
|
||||
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not run OpenCode backfill for explicit non-OpenCode teams even if stale runtime files exist', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
|
@ -1182,12 +1525,11 @@ describe('ChangeExtractorService', () => {
|
|||
projectDir,
|
||||
workspaceRoot: projectPath,
|
||||
deliveryContextPath: expect.stringContaining('delivery-context.json'),
|
||||
deliveryContextHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
attributionMode: 'strict-delivery',
|
||||
evidenceMode: 'chain-only',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
expect(settled).toBe(false);
|
||||
expect(workerClient.computeTaskChanges).not.toHaveBeenCalled();
|
||||
pendingBackfill.resolve({
|
||||
|
|
@ -1289,6 +1631,7 @@ describe('ChangeExtractorService', () => {
|
|||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
|
||||
expect.stringContaining('delivery-context.json')
|
||||
);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('does not cache negative OpenCode backfill while delivery context already exists', async () => {
|
||||
|
|
@ -1331,24 +1674,30 @@ describe('ChangeExtractorService', () => {
|
|||
'utf8'
|
||||
);
|
||||
|
||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
teamName: input.teamName,
|
||||
taskId: input.taskId,
|
||||
projectDir: input.projectDir,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
dryRun: false,
|
||||
attributionMode: input.attributionMode,
|
||||
scannedSessions: 1,
|
||||
scannedToolparts: 0,
|
||||
candidateEvents: 0,
|
||||
importedEvents: 0,
|
||||
skippedEvents: 0,
|
||||
outcome: 'no-attribution',
|
||||
notices: [],
|
||||
diagnostics: [],
|
||||
}));
|
||||
let backfillAttempt = 0;
|
||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => {
|
||||
const outcome = backfillAttempt++ === 0 ? 'transient-error' : 'no-attribution';
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
teamName: input.teamName,
|
||||
taskId: input.taskId,
|
||||
projectDir: input.projectDir,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
dryRun: false,
|
||||
attributionMode: input.attributionMode,
|
||||
scannedSessions: 1,
|
||||
scannedToolparts: 0,
|
||||
candidateEvents: 0,
|
||||
importedEvents: 0,
|
||||
skippedEvents: 0,
|
||||
outcome,
|
||||
notices: [],
|
||||
diagnostics: outcome === 'transient-error'
|
||||
? ['OpenCode SQLite file changed while snapshot was read; using transaction snapshot.']
|
||||
: [],
|
||||
};
|
||||
});
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
|
|
@ -1394,8 +1743,158 @@ describe('ChangeExtractorService', () => {
|
|||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextPath).toEqual(
|
||||
expect.stringContaining('delivery-context.json')
|
||||
);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[0]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextPath).toEqual(
|
||||
expect.stringContaining('delivery-context.json')
|
||||
);
|
||||
expect(backfillOpenCodeTaskLedger.mock.calls[1]?.[0]?.deliveryContextHash).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
it('does not cache duplicates-only OpenCode backfill from an old evidence contract', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
||||
const projectDir = path.join(tmpDir, 'project-dir');
|
||||
const projectPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeOpenCodeDeliveryLedger(tmpDir);
|
||||
|
||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
teamName: input.teamName,
|
||||
taskId: input.taskId,
|
||||
projectDir: input.projectDir,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
dryRun: false,
|
||||
attributionMode: input.attributionMode,
|
||||
scannedSessions: 1,
|
||||
scannedToolparts: 1,
|
||||
candidateEvents: 1,
|
||||
importedEvents: 0,
|
||||
skippedEvents: 1,
|
||||
outcome: 'duplicates-only',
|
||||
notices: [],
|
||||
diagnostics: [],
|
||||
}));
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
||||
),
|
||||
};
|
||||
|
||||
const service = new ChangeExtractorService(
|
||||
{
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir,
|
||||
projectPath,
|
||||
sessionIds: [],
|
||||
})),
|
||||
findLogFileRefsForTask: vi.fn(async () => []),
|
||||
findMemberLogPaths: vi.fn(async () => []),
|
||||
} as any,
|
||||
{
|
||||
parseBoundaries: vi.fn(async () => ({
|
||||
boundaries: [],
|
||||
scopes: [],
|
||||
isSingleTaskSession: true,
|
||||
detectedMechanism: 'none' as const,
|
||||
})),
|
||||
} as any,
|
||||
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
||||
undefined,
|
||||
workerClient as any,
|
||||
{ backfillOpenCodeTaskLedger } as any,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
||||
);
|
||||
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
});
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(2);
|
||||
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caches duplicates-only OpenCode backfill from the current evidence contract', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
await writeTaskFile(tmpDir, { displayId: 'abc12345', owner: 'bob' });
|
||||
const projectDir = path.join(tmpDir, 'project-dir');
|
||||
const projectPath = path.join(tmpDir, 'repo');
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeOpenCodeDeliveryLedger(tmpDir);
|
||||
|
||||
const backfillOpenCodeTaskLedger = vi.fn(async (input: any) => ({
|
||||
schemaVersion: 1,
|
||||
providerId: 'opencode',
|
||||
opencodeTaskLedgerEvidenceContractVersion:
|
||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
teamName: input.teamName,
|
||||
taskId: input.taskId,
|
||||
projectDir: input.projectDir,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
dryRun: false,
|
||||
attributionMode: input.attributionMode,
|
||||
scannedSessions: 1,
|
||||
scannedToolparts: 1,
|
||||
candidateEvents: 1,
|
||||
importedEvents: 0,
|
||||
skippedEvents: 1,
|
||||
outcome: 'duplicates-only',
|
||||
notices: [],
|
||||
diagnostics: [],
|
||||
}));
|
||||
const workerClient = {
|
||||
isAvailable: vi.fn(() => true),
|
||||
computeTaskChanges: vi.fn(async () =>
|
||||
makeTaskChangeResult(TASK_ID, { content: '', confidence: 'fallback' })
|
||||
),
|
||||
};
|
||||
|
||||
const service = new ChangeExtractorService(
|
||||
{
|
||||
getLogSourceWatchContext: vi.fn(async () => ({
|
||||
projectDir,
|
||||
projectPath,
|
||||
sessionIds: [],
|
||||
})),
|
||||
findLogFileRefsForTask: vi.fn(async () => []),
|
||||
findMemberLogPaths: vi.fn(async () => []),
|
||||
} as any,
|
||||
{
|
||||
parseBoundaries: vi.fn(async () => ({
|
||||
boundaries: [],
|
||||
scopes: [],
|
||||
isSingleTaskSession: true,
|
||||
detectedMechanism: 'none' as const,
|
||||
})),
|
||||
} as any,
|
||||
{ getConfig: vi.fn(async () => ({ projectPath })) } as any,
|
||||
undefined,
|
||||
workerClient as any,
|
||||
{ backfillOpenCodeTaskLedger } as any,
|
||||
{ getMeta: vi.fn(async () => ({ providerId: 'opencode' })) } as any
|
||||
);
|
||||
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
});
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
owner: 'bob',
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
expect(backfillOpenCodeTaskLedger).toHaveBeenCalledTimes(1);
|
||||
expect(workerClient.computeTaskChanges).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
createOpenCodeBridgeHandshakeIdentityHash,
|
||||
createOpenCodeBridgeIdempotencyKey,
|
||||
isOpenCodeBridgeCommandName,
|
||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION,
|
||||
parseSingleBridgeJsonResult,
|
||||
stableHash,
|
||||
validateBridgeResultEnvelope,
|
||||
|
|
@ -202,6 +203,42 @@ describe('OpenCodeBridgeCommandContract', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('accepts handshake evidence contract version and rejects invalid values', () => {
|
||||
const client = peerIdentity('claude_team');
|
||||
const server = peerIdentity('agent_teams_orchestrator');
|
||||
server.bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion =
|
||||
OPEN_CODE_TASK_LEDGER_EVIDENCE_CONTRACT_VERSION;
|
||||
const validHandshake = buildHandshake({ client, server });
|
||||
|
||||
expect(
|
||||
validateOpenCodeBridgeHandshake({
|
||||
handshake: validHandshake,
|
||||
expectedClient: client,
|
||||
requiredCommand: 'opencode.launchTeam',
|
||||
expectedCapabilitySnapshotId: 'cap-1',
|
||||
expectedManifestHighWatermark: 10,
|
||||
expectedRunId: 'run-1',
|
||||
})
|
||||
).toEqual({ ok: true });
|
||||
|
||||
server.bridgeProtocol.opencodeTaskLedgerEvidenceContractVersion = 0;
|
||||
const invalidHandshake = buildHandshake({ client, server });
|
||||
|
||||
expect(
|
||||
validateOpenCodeBridgeHandshake({
|
||||
handshake: invalidHandshake,
|
||||
expectedClient: client,
|
||||
requiredCommand: 'opencode.launchTeam',
|
||||
expectedCapabilitySnapshotId: 'cap-1',
|
||||
expectedManifestHighWatermark: 10,
|
||||
expectedRunId: 'run-1',
|
||||
})
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: 'Bridge handshake peer identity is invalid',
|
||||
});
|
||||
});
|
||||
|
||||
it('creates deterministic idempotency keys for equivalent JSON bodies', () => {
|
||||
const first = createOpenCodeBridgeIdempotencyKey({
|
||||
command: 'opencode.launchTeam',
|
||||
|
|
|
|||
|
|
@ -170,6 +170,8 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
taskDisplayId: 'abc12345',
|
||||
projectDir: '/claude/project',
|
||||
workspaceRoot: '/repo',
|
||||
deliveryContextPath: '/tmp/claude-team-opencode-ledger-context-test/delivery-context.json',
|
||||
deliveryContextHash: 'a'.repeat(64),
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
outcome: 'imported',
|
||||
|
|
@ -184,6 +186,8 @@ describe('OpenCodeReadinessBridge', () => {
|
|||
taskDisplayId: 'abc12345',
|
||||
projectDir: '/claude/project',
|
||||
workspaceRoot: '/repo',
|
||||
deliveryContextPath: '/tmp/claude-team-opencode-ledger-context-test/delivery-context.json',
|
||||
deliveryContextHash: 'a'.repeat(64),
|
||||
},
|
||||
{
|
||||
cwd: '/repo',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
||||
import { createHash } from 'crypto';
|
||||
import { structuredPatch } from 'diff';
|
||||
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
|
||||
|
||||
import type { SnippetDiff } from '@shared/types';
|
||||
|
||||
|
|
@ -985,7 +986,8 @@ describe('ReviewApplierService', () => {
|
|||
{
|
||||
filePath,
|
||||
fileDecision: 'pending',
|
||||
hunkDecisions: { 0: 'rejected' },
|
||||
hunkDecisions: { 0: 'rejected', 1: 'pending' },
|
||||
hunkContextHashes: buildHunkContextHashes(original, modified),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -1034,8 +1036,99 @@ describe('ReviewApplierService', () => {
|
|||
expect(res).toMatchObject({ applied: 1, conflicts: 0 });
|
||||
expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8');
|
||||
});
|
||||
|
||||
it('ledger partial reject refuses stale hunk context instead of falling back to index', async () => {
|
||||
const fsPromises = await import('fs/promises');
|
||||
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
|
||||
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
|
||||
|
||||
const filePath = '/tmp/stale-ledger.ts';
|
||||
const original = 'const value = 1;\nconst keep = true;\n';
|
||||
const modified = 'const value = 2;\nconst keep = true;\n';
|
||||
readFile.mockResolvedValue(modified);
|
||||
|
||||
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
|
||||
const svc = new ReviewApplierService();
|
||||
|
||||
const res = await svc.applyReviewDecisions(
|
||||
{
|
||||
teamName: 'team',
|
||||
decisions: [
|
||||
{
|
||||
filePath,
|
||||
fileDecision: 'pending',
|
||||
hunkDecisions: { 0: 'rejected', 1: 'pending' },
|
||||
hunkContextHashes: { 0: 'stale-context-hash' },
|
||||
},
|
||||
],
|
||||
},
|
||||
new Map([
|
||||
[
|
||||
filePath,
|
||||
{
|
||||
filePath,
|
||||
relativePath: 'stale-ledger.ts',
|
||||
snippets: [
|
||||
{
|
||||
toolUseId: 'ledger-1',
|
||||
filePath,
|
||||
toolName: 'Edit',
|
||||
type: 'edit',
|
||||
oldString: 'const value = 1;\n',
|
||||
newString: 'const value = 2;\n',
|
||||
replaceAll: false,
|
||||
timestamp: '2026-03-01T10:00:00.000Z',
|
||||
isError: false,
|
||||
ledger: {
|
||||
eventId: 'event-1',
|
||||
source: 'ledger-exact',
|
||||
confidence: 'exact',
|
||||
originalFullContent: original,
|
||||
modifiedFullContent: modified,
|
||||
beforeHash: sha(original),
|
||||
afterHash: sha(modified),
|
||||
operation: 'modify',
|
||||
beforeState: { exists: true, sha256: sha(original) },
|
||||
afterState: { exists: true, sha256: sha(modified) },
|
||||
},
|
||||
},
|
||||
],
|
||||
linesAdded: 1,
|
||||
linesRemoved: 1,
|
||||
isNewFile: false,
|
||||
originalFullContent: original,
|
||||
modifiedFullContent: modified,
|
||||
contentSource: 'ledger-exact',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
expect(res.applied).toBe(0);
|
||||
expect(res.conflicts).toBe(1);
|
||||
expect(res.errors[0]?.code).toBe('conflict');
|
||||
expect(writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function sha(content: string): string {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function buildHunkContextHashes(original: string, modified: string): Record<number, string> {
|
||||
const patch = structuredPatch('file', 'file', original, modified);
|
||||
const out: Record<number, string> = {};
|
||||
for (let i = 0; i < patch.hunks.length; i++) {
|
||||
const hunk = patch.hunks[i]!;
|
||||
const oldSideContent = hunk.lines
|
||||
.filter((line) => !line.startsWith('+'))
|
||||
.map((line) => line.slice(1))
|
||||
.join('\n');
|
||||
const newSideContent = hunk.lines
|
||||
.filter((line) => !line.startsWith('-'))
|
||||
.map((line) => line.slice(1))
|
||||
.join('\n');
|
||||
out[i] = computeDiffContextHash(oldSideContent, newSideContent);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -285,6 +285,84 @@ describe('TaskChangeLedgerReader', () => {
|
|||
expect(snippets[2]?.ledger?.source).toBe('ledger-snapshot');
|
||||
});
|
||||
|
||||
it('projects partial OpenCode snapshot journal evidence to a later full-text upgrade', async () => {
|
||||
tmpDir = await fsTempDir();
|
||||
const eventsDir = path.join(tmpDir, '.board-task-changes', 'events');
|
||||
const blobsDir = path.join(tmpDir, '.board-task-changes', 'blobs');
|
||||
await mkdir(eventsDir, { recursive: true });
|
||||
await mkdir(blobsDir, { recursive: true });
|
||||
|
||||
const beforeContent = 'export const value = 1;\n';
|
||||
const afterContent = 'export const value = 2;\n';
|
||||
await writeFile(path.join(blobsDir, 'before.txt'), beforeContent, 'utf8');
|
||||
await writeFile(path.join(blobsDir, 'after.txt'), afterContent, 'utf8');
|
||||
const sourceImportKey = 'opencode\0session-1\0part-edit\0src/file.ts';
|
||||
const baseEvent = {
|
||||
schemaVersion: 1,
|
||||
taskId: TASK_ID,
|
||||
taskRef: TASK_ID,
|
||||
taskRefKind: 'canonical',
|
||||
phase: 'work',
|
||||
executionSeq: 1,
|
||||
sessionId: 'opencode-session-1',
|
||||
memberName: 'bob',
|
||||
toolUseId: 'part-edit',
|
||||
source: 'opencode_toolpart_edit',
|
||||
operation: 'modify',
|
||||
confidence: 'high',
|
||||
workspaceRoot: '/repo',
|
||||
filePath: '/repo/src/file.ts',
|
||||
relativePath: 'src/file.ts',
|
||||
timestamp: '2026-03-01T10:00:00.000Z',
|
||||
toolStatus: 'succeeded',
|
||||
sourceRuntime: 'opencode',
|
||||
sourceProvider: 'opencode',
|
||||
sourceImportKey,
|
||||
evidenceProof: 'opencode-snapshot',
|
||||
beforeState: { exists: true, sha256: sha(beforeContent), sizeBytes: beforeContent.length },
|
||||
afterState: { exists: true, sha256: sha(afterContent), sizeBytes: afterContent.length },
|
||||
linesAdded: 1,
|
||||
linesRemoved: 1,
|
||||
};
|
||||
await writeFile(
|
||||
path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`),
|
||||
[
|
||||
{
|
||||
...baseEvent,
|
||||
eventId: 'event-partial',
|
||||
before: null,
|
||||
after: { sha256: sha(afterContent), sizeBytes: afterContent.length, blobRef: 'after.txt' },
|
||||
},
|
||||
{
|
||||
...baseEvent,
|
||||
eventId: 'event-full',
|
||||
supersedesEventId: 'event-partial',
|
||||
before: { sha256: sha(beforeContent), sizeBytes: beforeContent.length, blobRef: 'before.txt' },
|
||||
after: { sha256: sha(afterContent), sizeBytes: afterContent.length, blobRef: 'after.txt' },
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const reader = new TaskChangeLedgerReader();
|
||||
const result = await reader.readTaskChanges({
|
||||
teamName: 'team',
|
||||
taskId: TASK_ID,
|
||||
projectDir: tmpDir,
|
||||
projectPath: '/repo',
|
||||
includeDetails: true,
|
||||
});
|
||||
|
||||
expect(result?.files).toHaveLength(1);
|
||||
const snippets = result?.files[0]?.snippets ?? [];
|
||||
expect(snippets).toHaveLength(1);
|
||||
expect(snippets[0]?.ledger?.eventId).toBe('event-full');
|
||||
expect(snippets[0]?.ledger?.originalFullContent).toBe(beforeContent);
|
||||
expect(snippets[0]?.ledger?.modifiedFullContent).toBe(afterContent);
|
||||
});
|
||||
|
||||
it('groups rename relations in summary-only bundles without losing absolute paths', async () => {
|
||||
const relation = { kind: 'rename', oldPath: 'src/old.ts', newPath: 'src/new.ts' };
|
||||
tmpDir = await makeLedgerBundle({
|
||||
|
|
@ -614,6 +692,91 @@ describe('TaskChangeLedgerReader', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('keeps v2 provenance fingerprint stable when only raw journal metadata changes', async () => {
|
||||
tmpDir = await makeSummaryLedgerBundleV2({
|
||||
bundle: {
|
||||
journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'raw-a' } },
|
||||
eventCount: 1,
|
||||
noticeCount: 0,
|
||||
warningCount: 0,
|
||||
warnings: [],
|
||||
},
|
||||
file: {
|
||||
eventCount: 1,
|
||||
firstTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
lastTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
agentIds: ['alice@team'],
|
||||
},
|
||||
});
|
||||
const reader = new TaskChangeLedgerReader();
|
||||
const first = await reader.readTaskChanges({
|
||||
teamName: 'team',
|
||||
taskId: TASK_ID,
|
||||
projectDir: tmpDir,
|
||||
projectPath: '/repo',
|
||||
includeDetails: false,
|
||||
});
|
||||
|
||||
tmpDir = await makeSummaryLedgerBundleV2({
|
||||
bundle: {
|
||||
generatedAt: '2026-03-01T11:00:00.000Z',
|
||||
journalStamp: { events: { bytes: 999, mtimeMs: 99, tailSha256: 'raw-b' } },
|
||||
eventCount: 7,
|
||||
noticeCount: 3,
|
||||
warningCount: 1,
|
||||
warnings: ['raw journal had a recovered warning'],
|
||||
},
|
||||
file: {
|
||||
eventCount: 7,
|
||||
firstTimestamp: '2026-03-01T09:00:00.000Z',
|
||||
lastTimestamp: '2026-03-01T11:00:00.000Z',
|
||||
agentIds: ['alice@team', 'bob@team'],
|
||||
},
|
||||
});
|
||||
const second = await reader.readTaskChanges({
|
||||
teamName: 'team',
|
||||
taskId: TASK_ID,
|
||||
projectDir: tmpDir,
|
||||
projectPath: '/repo',
|
||||
includeDetails: false,
|
||||
});
|
||||
|
||||
expect(first?.provenance?.sourceFingerprint).toBe(second?.provenance?.sourceFingerprint);
|
||||
});
|
||||
|
||||
it('changes v2 provenance fingerprint when projected file evidence changes', async () => {
|
||||
tmpDir = await makeSummaryLedgerBundleV2({
|
||||
file: {
|
||||
latestAfterHash: sha('after-v1'),
|
||||
latestAfterState: { exists: true, sha256: sha('after-v1'), sizeBytes: 8 },
|
||||
},
|
||||
});
|
||||
const reader = new TaskChangeLedgerReader();
|
||||
const first = await reader.readTaskChanges({
|
||||
teamName: 'team',
|
||||
taskId: TASK_ID,
|
||||
projectDir: tmpDir,
|
||||
projectPath: '/repo',
|
||||
includeDetails: false,
|
||||
});
|
||||
|
||||
tmpDir = await makeSummaryLedgerBundleV2({
|
||||
file: {
|
||||
latestAfterHash: sha('after-v2'),
|
||||
latestAfterState: { exists: true, sha256: sha('after-v2'), sizeBytes: 8 },
|
||||
},
|
||||
});
|
||||
const second = await reader.readTaskChanges({
|
||||
teamName: 'team',
|
||||
taskId: TASK_ID,
|
||||
projectDir: tmpDir,
|
||||
projectPath: '/repo',
|
||||
includeDetails: false,
|
||||
});
|
||||
|
||||
expect(first?.provenance?.sourceFingerprint).not.toBe(second?.provenance?.sourceFingerprint);
|
||||
});
|
||||
|
||||
it('keeps identical relative rename relations isolated by worktree path', async () => {
|
||||
tmpDir = await fsTempDir();
|
||||
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
|
||||
|
|
@ -969,6 +1132,74 @@ async function makeLedgerBundle(params: {
|
|||
return dir;
|
||||
}
|
||||
|
||||
async function makeSummaryLedgerBundleV2(params: {
|
||||
bundle?: Record<string, unknown>;
|
||||
file?: Record<string, unknown>;
|
||||
} = {}): Promise<string> {
|
||||
const dir = await fsTempDir();
|
||||
const bundleDir = path.join(dir, '.board-task-changes', 'bundles');
|
||||
await mkdir(bundleDir, { recursive: true });
|
||||
const file = {
|
||||
changeKey: 'path:/repo/src/file.ts',
|
||||
filePath: '/repo/src/file.ts',
|
||||
relativePath: 'src/file.ts',
|
||||
linesAdded: 1,
|
||||
linesRemoved: 1,
|
||||
diffStatKnown: true,
|
||||
eventCount: 1,
|
||||
firstTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
lastTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
latestOperation: 'modify',
|
||||
createdInTask: false,
|
||||
deletedInTask: false,
|
||||
latestBeforeHash: sha('before'),
|
||||
latestAfterHash: sha('after'),
|
||||
latestBeforeState: { exists: true, sha256: sha('before'), sizeBytes: 6 },
|
||||
latestAfterState: { exists: true, sha256: sha('after'), sizeBytes: 5 },
|
||||
contentAvailability: 'full-text',
|
||||
reviewability: 'full-text',
|
||||
agentIds: ['alice@team'],
|
||||
...params.file,
|
||||
};
|
||||
await writeFile(
|
||||
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
source: 'task-change-ledger',
|
||||
bundleKind: 'summary',
|
||||
taskId: TASK_ID,
|
||||
generatedAt: '2026-03-01T10:00:00.000Z',
|
||||
journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'raw' } },
|
||||
integrity: 'ok',
|
||||
eventCount: 1,
|
||||
noticeCount: 0,
|
||||
scope: {
|
||||
confidence: { tier: 1, label: 'high', reason: 'bundle' },
|
||||
memberName: 'alice',
|
||||
agentIds: ['alice@team'],
|
||||
startTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
endTimestamp: '2026-03-01T10:00:00.000Z',
|
||||
toolUseIds: ['tool-1'],
|
||||
toolUseCount: 1,
|
||||
phaseSet: ['work'],
|
||||
visibleFileCount: 1,
|
||||
contributors: [],
|
||||
},
|
||||
files: [file],
|
||||
totalLinesAdded: 1,
|
||||
totalLinesRemoved: 1,
|
||||
diffStatCompleteness: 'complete',
|
||||
totalFiles: 1,
|
||||
confidence: 'high',
|
||||
warningCount: 0,
|
||||
warnings: [],
|
||||
...params.bundle,
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function fsTempDir(): Promise<string> {
|
||||
return mkdtemp(path.join(os.tmpdir(), 'ledger-reader-'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,6 +237,74 @@ afterEach(async () => {
|
|||
);
|
||||
});
|
||||
|
||||
describe('TeamDataService draft metadata', () => {
|
||||
it('round-trips create config metadata through getSavedRequest', async () => {
|
||||
const claudeRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-data-saved-request-'));
|
||||
tempPaths.push(claudeRoot);
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
|
||||
const service = new TeamDataService();
|
||||
await service.createTeamConfig({
|
||||
teamName: 'draft-team',
|
||||
displayName: 'Draft Team',
|
||||
description: 'Saved draft',
|
||||
color: '#3366ff',
|
||||
cwd: '/Users/test/project',
|
||||
prompt: 'Saved prompt',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
skipPermissions: false,
|
||||
worktree: 'feature-x',
|
||||
extraCliArgs: '--max-turns 5',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
role: 'Engineer',
|
||||
workflow: 'Ship focused patches',
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(service.getSavedRequest('missing-team')).resolves.toBeNull();
|
||||
await expect(service.getSavedRequest('draft-team')).resolves.toMatchObject({
|
||||
teamName: 'draft-team',
|
||||
displayName: 'Draft Team',
|
||||
description: 'Saved draft',
|
||||
color: '#3366ff',
|
||||
cwd: '/Users/test/project',
|
||||
prompt: 'Saved prompt',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
limitContext: true,
|
||||
skipPermissions: false,
|
||||
worktree: 'feature-x',
|
||||
extraCliArgs: '--max-turns 5',
|
||||
members: [
|
||||
{
|
||||
name: 'builder',
|
||||
role: 'Engineer',
|
||||
workflow: 'Ship focused patches',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.2',
|
||||
effort: 'high',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createForwardingJournalStore(initialEntries: Array<Record<string, unknown>> = []) {
|
||||
const journalEntries = initialEntries;
|
||||
const journal = {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
|||
};
|
||||
});
|
||||
|
||||
import { setAppDataBasePath } from '@main/utils/pathDecoder';
|
||||
import { setAppDataBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||
import { TeamMcpConfigBuilder } from '@main/services/team/TeamMcpConfigBuilder';
|
||||
|
||||
describe('TeamMcpConfigBuilder', () => {
|
||||
|
|
@ -77,10 +77,10 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
|
||||
function readGeneratedServer(
|
||||
configPath: string
|
||||
): { command?: string; args?: string[] } | undefined {
|
||||
): { command?: string; args?: string[]; env?: Record<string, string> } | undefined {
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = JSON.parse(raw) as {
|
||||
mcpServers?: Record<string, { command?: string; args?: string[] }>;
|
||||
mcpServers?: Record<string, { command?: string; args?: string[]; env?: Record<string, string> }>;
|
||||
};
|
||||
return parsed.mcpServers?.['agent-teams'];
|
||||
}
|
||||
|
|
@ -180,6 +180,7 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
|
||||
afterEach(() => {
|
||||
setAppDataBasePath(null);
|
||||
setClaudeBasePathOverride(null);
|
||||
setPackagedMode(false);
|
||||
setResourcesPath(originalResourcesPath);
|
||||
moduleInternal._load = originalModuleLoad;
|
||||
|
|
@ -370,6 +371,20 @@ describe('TeamMcpConfigBuilder', () => {
|
|||
expectTsxEntry(parsed.mcpServers['agent-teams'], sourceEntry);
|
||||
});
|
||||
|
||||
it('passes the configured Claude root to the MCP server', async () => {
|
||||
const claudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-claude-root-'));
|
||||
createdDirs.push(claudeRoot);
|
||||
setClaudeBasePathOverride(claudeRoot);
|
||||
|
||||
const builder = new TeamMcpConfigBuilder();
|
||||
const configPath = await builder.writeConfigFile();
|
||||
createdPaths.push(configPath);
|
||||
|
||||
expect(readGeneratedServer(configPath)?.env).toMatchObject({
|
||||
AGENT_TEAMS_MCP_CLAUDE_DIR: claudeRoot,
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores malformed user MCP file', async () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-home-'));
|
||||
const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-project-'));
|
||||
|
|
|
|||
|
|
@ -315,6 +315,50 @@ describe('task change ledger golden fixtures', () => {
|
|||
expect(resolved.contentSource).toBe('ledger-snapshot');
|
||||
});
|
||||
|
||||
it('reads OpenCode snapshot upgrade fixtures as one full-text ledger row', async () => {
|
||||
const fixture = await materializeTaskChangeLedgerFixture('opencode-snapshot-upgrade');
|
||||
cleanups.push(fixture.cleanup);
|
||||
const reader = new TaskChangeLedgerReader();
|
||||
const changeSet = await reader.readTaskChanges({
|
||||
teamName: TEAM_NAME,
|
||||
taskId: fixture.manifest.taskId,
|
||||
projectDir: fixture.projectDir,
|
||||
projectPath: fixture.projectDir,
|
||||
includeDetails: true,
|
||||
});
|
||||
|
||||
expect(changeSet?.files).toHaveLength(1);
|
||||
const file = changeSet!.files[0]!;
|
||||
expect(file.relativePath).toBe('src/snapshot-only.js');
|
||||
expect(file.ledgerSummary).toMatchObject({
|
||||
reviewability: 'full-text',
|
||||
contentAvailability: 'full-text',
|
||||
});
|
||||
expect(file.snippets).toHaveLength(1);
|
||||
const snippet = file.snippets[0]!;
|
||||
expect(snippet.toolName).toBe('Edit');
|
||||
expect(snippet.type).toBe('edit');
|
||||
expect(snippet.ledger).toMatchObject({
|
||||
source: 'ledger-snapshot',
|
||||
confidence: 'high',
|
||||
textAvailability: 'full-text',
|
||||
operation: 'modify',
|
||||
});
|
||||
expect(snippet.ledger?.originalFullContent).toBe('export const snapshot = 1;\n');
|
||||
expect(snippet.ledger?.modifiedFullContent).toBe('export const snapshot = 2;\n');
|
||||
|
||||
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
|
||||
const resolved = await resolver.getFileContent(
|
||||
TEAM_NAME,
|
||||
'bob',
|
||||
file.filePath,
|
||||
file.snippets
|
||||
);
|
||||
expect(resolved.originalFullContent).toBe('export const snapshot = 1;\n');
|
||||
expect(resolved.modifiedFullContent).toBe('export const snapshot = 2;\n');
|
||||
expect(resolved.contentSource).toBe('ledger-snapshot');
|
||||
});
|
||||
|
||||
it('rejects grouped copy fixtures by deleting only the copied path', async () => {
|
||||
const fixture = await materializeTaskChangeLedgerFixture('copy');
|
||||
cleanups.push(fixture.cleanup);
|
||||
|
|
|
|||
179
test/main/utils/AgentCliLaunch.live-e2e.test.ts
Normal file
179
test/main/utils/AgentCliLaunch.live-e2e.test.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
// @vitest-environment node
|
||||
import { execFile } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { CodexBinaryResolver } from '@main/services/infrastructure/codexAppServer/CodexBinaryResolver';
|
||||
import { execCli, killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const liveDescribe = process.env.AGENT_CLI_LAUNCH_LIVE_E2E === '1' ? describe : describe.skip;
|
||||
const CLI_LAUNCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
type AgentCliProvider = 'opencode' | 'codex' | 'claude';
|
||||
|
||||
type AgentCliSpec = {
|
||||
providerId: AgentCliProvider;
|
||||
command: string;
|
||||
overrideEnv: string;
|
||||
versionPattern: RegExp;
|
||||
resolver?: () => Promise<string | null>;
|
||||
};
|
||||
|
||||
const AGENT_CLI_SPECS: AgentCliSpec[] = [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
command: 'opencode',
|
||||
overrideEnv: 'OPENCODE_CLI_PATH',
|
||||
versionPattern: /\b\d+\.\d+\.\d+\b/,
|
||||
},
|
||||
{
|
||||
providerId: 'codex',
|
||||
command: 'codex',
|
||||
overrideEnv: 'CODEX_CLI_PATH',
|
||||
versionPattern: /\b(?:codex-cli\s+)?\d+\.\d+\.\d+\b/i,
|
||||
resolver: () => CodexBinaryResolver.resolve(),
|
||||
},
|
||||
{
|
||||
providerId: 'claude',
|
||||
command: 'claude',
|
||||
overrideEnv: 'CLAUDE_CLI_PATH',
|
||||
versionPattern: /\b\d+\.\d+\.\d+\b.*Claude Code/i,
|
||||
},
|
||||
];
|
||||
|
||||
liveDescribe('agent CLI launch live e2e', () => {
|
||||
it.each(AGENT_CLI_SPECS)(
|
||||
'resolves and executes $providerId through execCli without tmux',
|
||||
async (spec) => {
|
||||
const binaryPath = await resolveCliBinary(spec);
|
||||
expect(binaryPath, `${spec.providerId} binary must be installed`).toBeTruthy();
|
||||
|
||||
const result = await execCli(binaryPath, ['--version'], {
|
||||
timeout: CLI_LAUNCH_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
});
|
||||
const output = `${result.stdout}\n${result.stderr}`.trim();
|
||||
|
||||
expect(output).toMatch(spec.versionPattern);
|
||||
expect(output).not.toMatch(/tmux/i);
|
||||
expect(output).not.toMatch(/running scripts is disabled/i);
|
||||
expect(output).not.toMatch(/not digitally signed/i);
|
||||
},
|
||||
CLI_LAUNCH_TIMEOUT_MS + 5_000
|
||||
);
|
||||
|
||||
it.each(AGENT_CLI_SPECS)(
|
||||
'spawns $providerId through spawnCli and exits cleanly without tmux',
|
||||
async (spec) => {
|
||||
const binaryPath = await resolveCliBinary(spec);
|
||||
expect(binaryPath, `${spec.providerId} binary must be installed`).toBeTruthy();
|
||||
|
||||
const result = await spawnAndCollect(binaryPath, ['--version']);
|
||||
const output = `${result.stdout}\n${result.stderr}`.trim();
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(output).toMatch(spec.versionPattern);
|
||||
expect(output).not.toMatch(/tmux/i);
|
||||
expect(output).not.toMatch(/running scripts is disabled/i);
|
||||
expect(output).not.toMatch(/not digitally signed/i);
|
||||
},
|
||||
CLI_LAUNCH_TIMEOUT_MS + 5_000
|
||||
);
|
||||
});
|
||||
|
||||
async function resolveCliBinary(spec: AgentCliSpec): Promise<string> {
|
||||
const override = process.env[spec.overrideEnv]?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
if (spec.resolver) {
|
||||
const resolved = await spec.resolver();
|
||||
if (resolved) {
|
||||
return preferWindowsCmdShim(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
return preferWindowsCmdShim(await resolveCommandFromPath(spec.command));
|
||||
}
|
||||
|
||||
async function resolveCommandFromPath(command: string): Promise<string> {
|
||||
if (process.platform === 'win32') {
|
||||
const { stdout } = await execFileAsync('where.exe', [command], {
|
||||
timeout: CLI_LAUNCH_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
});
|
||||
const candidates = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const cmdCandidate = candidates.find((candidate) => /\.cmd$/i.test(candidate));
|
||||
return cmdCandidate ?? candidates[0] ?? command;
|
||||
}
|
||||
|
||||
const { stdout } = await execFileAsync('which', [command], {
|
||||
timeout: CLI_LAUNCH_TIMEOUT_MS,
|
||||
});
|
||||
return stdout.trim().split(/\r?\n/)[0] ?? command;
|
||||
}
|
||||
|
||||
function preferWindowsCmdShim(binaryPath: string): string {
|
||||
if (process.platform !== 'win32') {
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
const extension = path.extname(binaryPath).toLowerCase();
|
||||
if (extension === '.cmd') {
|
||||
return binaryPath;
|
||||
}
|
||||
|
||||
const cmdPeer = extension ? `${binaryPath.slice(0, -extension.length)}.cmd` : `${binaryPath}.cmd`;
|
||||
return fs.existsSync(cmdPeer) ? cmdPeer : binaryPath;
|
||||
}
|
||||
|
||||
function spawnAndCollect(
|
||||
binaryPath: string,
|
||||
args: string[]
|
||||
): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawnCli(binaryPath, args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
const stdoutChunks: Buffer[] = [];
|
||||
const stderrChunks: Buffer[] = [];
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
killProcessTree(child, 'SIGKILL');
|
||||
reject(new Error(`Timed out launching ${binaryPath}`));
|
||||
}
|
||||
}, CLI_LAUNCH_TIMEOUT_MS);
|
||||
|
||||
child.stdout?.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)));
|
||||
child.stderr?.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)));
|
||||
child.once('error', (error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once('close', (exitCode) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve({
|
||||
exitCode,
|
||||
stdout: Buffer.concat(stdoutChunks).toString('utf8'),
|
||||
stderr: Buffer.concat(stderrChunks).toString('utf8'),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -64,6 +64,41 @@ function createGeneratedBunLauncher(): { dir: string; launcher: string; target:
|
|||
return { dir, launcher, target };
|
||||
}
|
||||
|
||||
function createExtensionlessNpmNodeLauncher(): {
|
||||
dir: string;
|
||||
launcher: string;
|
||||
target: string;
|
||||
} {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'cat-cli-npm-launcher-'));
|
||||
const targetDir = path.join(dir, 'node_modules', 'opencode-ai', 'bin');
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
const target = path.join(targetDir, 'opencode');
|
||||
writeFileSync(target, 'console.log("ok")', 'utf8');
|
||||
const launcher = path.join(dir, 'opencode.cmd');
|
||||
writeFileSync(
|
||||
launcher,
|
||||
[
|
||||
'@ECHO off',
|
||||
'GOTO start',
|
||||
':find_dp0',
|
||||
'SET dp0=%~dp0',
|
||||
'EXIT /b',
|
||||
':start',
|
||||
'SETLOCAL',
|
||||
'CALL :find_dp0',
|
||||
'IF EXIST "%dp0%\\node.exe" (',
|
||||
' SET "_prog=%dp0%\\node.exe"',
|
||||
') ELSE (',
|
||||
' SET "_prog=node"',
|
||||
')',
|
||||
'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\node_modules\\opencode-ai\\bin\\opencode" %*',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
'utf8'
|
||||
);
|
||||
return { dir, launcher, target };
|
||||
}
|
||||
|
||||
describe('cli child process helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
|
@ -152,6 +187,24 @@ describe('cli child process helpers', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('runs extensionless npm node cmd launchers directly', () => {
|
||||
setPlatform('win32');
|
||||
const fake = {} as any;
|
||||
const spawnMock = child.spawn as unknown as Mock;
|
||||
spawnMock.mockReturnValue(fake);
|
||||
const { dir, launcher, target } = createExtensionlessNpmNodeLauncher();
|
||||
try {
|
||||
const result = spawnCli(launcher, ['--model', 'test%PATH%"arg']);
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock.mock.calls[0][0]).toBe('node');
|
||||
expect(spawnMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']);
|
||||
expect(spawnMock.mock.calls[0][2]).not.toHaveProperty('shell');
|
||||
expect(result).toBe(fake);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('uses shell directly when path contains non-ASCII on windows', () => {
|
||||
setPlatform('win32');
|
||||
const fake = {} as any;
|
||||
|
|
@ -281,6 +334,29 @@ describe('cli child process helpers', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('executes extensionless npm node cmd launchers directly', async () => {
|
||||
setPlatform('win32');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
const execMock = child.exec as unknown as Mock;
|
||||
execFileMock.mockImplementation(
|
||||
(_cmd: string, _args: string[], _opts: unknown, cb: ExecCallback) => {
|
||||
cb(null, 'ok', '');
|
||||
return {} as any;
|
||||
}
|
||||
);
|
||||
const { dir, launcher, target } = createExtensionlessNpmNodeLauncher();
|
||||
try {
|
||||
const result = await execCli(launcher, ['--model', 'test%PATH%"arg']);
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(execFileMock.mock.calls[0][0]).toBe('node');
|
||||
expect(execFileMock.mock.calls[0][1]).toEqual([target, '--model', 'test%PATH%"arg']);
|
||||
expect(execMock).not.toHaveBeenCalled();
|
||||
expect(result.stdout).toBe('ok');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('skips straight to shell when path contains non-ASCII on windows', async () => {
|
||||
setPlatform('win32');
|
||||
const execFileMock = child.execFile as unknown as Mock;
|
||||
|
|
|
|||
|
|
@ -124,8 +124,10 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
|||
customRole?: string;
|
||||
workflow?: string;
|
||||
providerId?: string;
|
||||
providerBackendId?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
fastMode?: string;
|
||||
}>
|
||||
) =>
|
||||
drafts.map((draft) => ({
|
||||
|
|
@ -133,8 +135,10 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
|||
role: draft.customRole || undefined,
|
||||
workflow: draft.workflow,
|
||||
providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | undefined,
|
||||
providerBackendId: draft.providerBackendId as 'codex-native' | undefined,
|
||||
model: draft.model,
|
||||
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
|
||||
fastMode: draft.fastMode as 'inherit' | 'on' | 'off' | undefined,
|
||||
})),
|
||||
clearMemberModelOverrides: (member: unknown) => member,
|
||||
createMemberDraftsFromInputs: (
|
||||
|
|
@ -143,8 +147,10 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
|||
role?: string;
|
||||
workflow?: string;
|
||||
providerId?: string;
|
||||
providerBackendId?: string;
|
||||
model?: string;
|
||||
effort?: string;
|
||||
fastMode?: string;
|
||||
isolation?: 'worktree';
|
||||
}>
|
||||
) =>
|
||||
|
|
@ -157,8 +163,10 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
|||
workflow: member.workflow ?? '',
|
||||
isolation: member.isolation,
|
||||
providerId: member.providerId,
|
||||
providerBackendId: member.providerBackendId,
|
||||
model: member.model ?? '',
|
||||
effort: member.effort,
|
||||
fastMode: member.fastMode,
|
||||
})),
|
||||
filterEditableMemberInputs: (members: unknown) => members,
|
||||
normalizeLeadProviderForMode: (providerId: unknown) =>
|
||||
|
|
@ -587,6 +595,82 @@ describe('LaunchTeamDialog', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('preserves hidden teammate backend and fast mode metadata before draft launch', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
||||
teamName: 'team-alpha',
|
||||
cwd: '/tmp/project',
|
||||
providerId: 'anthropic',
|
||||
model: 'opus',
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
const onLaunch = vi.fn(async () => {});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(LaunchTeamDialog, {
|
||||
mode: 'launch',
|
||||
open: true,
|
||||
teamName: 'team-alpha',
|
||||
members: [],
|
||||
defaultProjectPath: '/tmp/project',
|
||||
provisioningError: null,
|
||||
clearProvisioningError: vi.fn(),
|
||||
activeTeams: [],
|
||||
onClose: vi.fn(),
|
||||
onLaunch,
|
||||
})
|
||||
);
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Launch team'
|
||||
);
|
||||
expect(submitButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
submitButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(vi.mocked(api.teams.replaceMembers).mock.calls[0]?.[1]).toMatchObject({
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4',
|
||||
effort: 'medium',
|
||||
fastMode: 'on',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(onLaunch).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits relaunch through onRelaunch without replacing members in-dialog', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,37 @@ describe('members editor editable input filtering', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('round-trips hidden teammate backend and fast mode metadata', () => {
|
||||
const drafts = createMemberDraftsFromInputs(
|
||||
filterEditableMemberInputs([
|
||||
{
|
||||
name: 'alice',
|
||||
agentType: 'reviewer',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4-mini',
|
||||
effort: 'medium',
|
||||
fastMode: 'on',
|
||||
},
|
||||
] as any)
|
||||
);
|
||||
|
||||
expect(drafts[0]).toMatchObject({
|
||||
providerBackendId: 'codex-native',
|
||||
fastMode: 'on',
|
||||
});
|
||||
expect(buildMembersFromDrafts(drafts)).toEqual([
|
||||
expect.objectContaining({
|
||||
name: 'alice',
|
||||
providerId: 'codex',
|
||||
providerBackendId: 'codex-native',
|
||||
model: 'gpt-5.4-mini',
|
||||
effort: 'medium',
|
||||
fastMode: 'on',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves explicit codex models when exporting member inputs', () => {
|
||||
const drafts = createMemberDraftsFromInputs(
|
||||
filterEditableMemberInputs([
|
||||
|
|
|
|||
|
|
@ -1651,4 +1651,153 @@ describe('changeReviewSlice task changes', () => {
|
|||
});
|
||||
expect(store.getState().activeChangeSet).toEqual(current);
|
||||
});
|
||||
|
||||
it('does not force re-review when ledger provenance stays stable despite warning changes', async () => {
|
||||
const store = createSliceStore();
|
||||
const current = {
|
||||
...makeTaskChangeSet('task-ledger', '/repo/file.ts'),
|
||||
provenance: {
|
||||
sourceKind: 'ledger',
|
||||
sourceFingerprint: 'projected-fp-stable',
|
||||
},
|
||||
warnings: [],
|
||||
};
|
||||
const fresh = {
|
||||
...current,
|
||||
computedAt: '2026-03-01T13:00:00.000Z',
|
||||
warnings: ['raw journal warning changed'],
|
||||
};
|
||||
hoisted.getTaskChanges.mockResolvedValueOnce(fresh);
|
||||
hoisted.applyDecisions.mockResolvedValueOnce({
|
||||
applied: 1,
|
||||
skipped: 0,
|
||||
conflicts: 0,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
store.setState({
|
||||
activeChangeSet: current,
|
||||
hunkDecisions: { '/repo/file.ts:0': 'rejected' },
|
||||
fileDecisions: { '/repo/file.ts': 'rejected' },
|
||||
fileChunkCounts: { '/repo/file.ts': 1 },
|
||||
changeSetEpoch: 0,
|
||||
fileContentVersionByPath: {},
|
||||
});
|
||||
|
||||
await store.getState().applyReview('team-a', 'task-ledger');
|
||||
|
||||
expect(store.getState().applyError).toBeNull();
|
||||
expect(hoisted.applyDecisions).toHaveBeenCalledTimes(1);
|
||||
expect(store.getState().activeChangeSet).toEqual(current);
|
||||
});
|
||||
|
||||
it('forces re-review when ledger projected provenance changes with the same file paths', async () => {
|
||||
const store = createSliceStore();
|
||||
const current = {
|
||||
...makeTaskChangeSet('task-ledger', '/repo/file.ts'),
|
||||
provenance: {
|
||||
sourceKind: 'ledger',
|
||||
sourceFingerprint: 'projected-fp-v1',
|
||||
},
|
||||
};
|
||||
const fresh = {
|
||||
...current,
|
||||
provenance: {
|
||||
sourceKind: 'ledger',
|
||||
sourceFingerprint: 'projected-fp-v2',
|
||||
},
|
||||
};
|
||||
hoisted.getTaskChanges.mockResolvedValueOnce(fresh);
|
||||
|
||||
store.setState({
|
||||
activeChangeSet: current,
|
||||
hunkDecisions: { '/repo/file.ts:0': 'rejected' },
|
||||
fileDecisions: { '/repo/file.ts': 'rejected' },
|
||||
fileChunkCounts: { '/repo/file.ts': 1 },
|
||||
reviewUndoStack: [{ hunkDecisions: { '/repo/file.ts:0': 'rejected' }, fileDecisions: { '/repo/file.ts': 'rejected' } }],
|
||||
changeSetEpoch: 2,
|
||||
fileContentVersionByPath: { '/repo/file.ts': 3 },
|
||||
});
|
||||
|
||||
await store.getState().applyReview('team-a', 'task-ledger');
|
||||
|
||||
expect(hoisted.applyDecisions).not.toHaveBeenCalled();
|
||||
expect(store.getState().activeChangeSet).toEqual(fresh);
|
||||
expect(store.getState().applyError).toBe(
|
||||
'Changes have been updated since you started reviewing. Please re-review.'
|
||||
);
|
||||
expect(store.getState().hunkDecisions).toEqual({});
|
||||
expect(store.getState().fileDecisions).toEqual({});
|
||||
expect(store.getState().reviewUndoStack).toEqual([]);
|
||||
expect(store.getState().fileContentVersionByPath).toEqual({});
|
||||
});
|
||||
|
||||
it('clears metadata-only decisions when ledger evidence upgrades to full text for the same changeKey', async () => {
|
||||
const store = createSliceStore();
|
||||
const changeKey = 'path:/repo/file.ts';
|
||||
const currentFile = {
|
||||
...makeFile('/repo/file.ts'),
|
||||
changeKey,
|
||||
snippets: [],
|
||||
ledgerSummary: {
|
||||
latestOperation: 'modify',
|
||||
contentAvailability: 'metadata-only',
|
||||
reviewability: 'metadata-only',
|
||||
},
|
||||
};
|
||||
const freshFile = {
|
||||
...makeFile('/repo/file.ts'),
|
||||
changeKey,
|
||||
ledgerSummary: {
|
||||
latestOperation: 'modify',
|
||||
contentAvailability: 'full-text',
|
||||
reviewability: 'full-text',
|
||||
beforeState: { exists: true, sha256: 'before-hash', sizeBytes: 6 },
|
||||
afterState: { exists: true, sha256: 'after-hash', sizeBytes: 5 },
|
||||
},
|
||||
};
|
||||
const current = {
|
||||
...makeTaskChangeSet('task-ledger', '/repo/file.ts'),
|
||||
files: [currentFile],
|
||||
provenance: {
|
||||
sourceKind: 'ledger',
|
||||
sourceFingerprint: 'metadata-only-projection',
|
||||
},
|
||||
};
|
||||
const fresh = {
|
||||
...current,
|
||||
files: [freshFile],
|
||||
provenance: {
|
||||
sourceKind: 'ledger',
|
||||
sourceFingerprint: 'snapshot-full-text-projection',
|
||||
},
|
||||
};
|
||||
hoisted.getTaskChanges.mockResolvedValueOnce(fresh);
|
||||
|
||||
store.setState({
|
||||
activeChangeSet: current,
|
||||
hunkDecisions: { [`${changeKey}:0`]: 'rejected' },
|
||||
fileDecisions: { [changeKey]: 'rejected' },
|
||||
hunkContextHashesByFile: { [changeKey]: { 0: 'metadata-only-context' } },
|
||||
fileChunkCounts: { [changeKey]: 1 },
|
||||
reviewUndoStack: [
|
||||
{
|
||||
hunkDecisions: { [`${changeKey}:0`]: 'rejected' },
|
||||
fileDecisions: { [changeKey]: 'rejected' },
|
||||
},
|
||||
],
|
||||
changeSetEpoch: 4,
|
||||
fileContentVersionByPath: { '/repo/file.ts': 2 },
|
||||
});
|
||||
|
||||
await store.getState().applyReview('team-a', 'task-ledger');
|
||||
|
||||
expect(hoisted.applyDecisions).not.toHaveBeenCalled();
|
||||
expect(store.getState().activeChangeSet).toEqual(fresh);
|
||||
expect(store.getState().fileDecisions).toEqual({});
|
||||
expect(store.getState().hunkDecisions).toEqual({});
|
||||
expect(store.getState().hunkContextHashesByFile).toEqual({});
|
||||
expect(store.getState().reviewUndoStack).toEqual([]);
|
||||
expect(store.getState().fileContentVersionByPath).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue