diff --git a/.gitignore b/.gitignore index d0c2ee9e4..84ba09520 100644 --- a/.gitignore +++ b/.gitignore @@ -213,5 +213,15 @@ agentkit.yaml agentkit*.yaml .agentkit/ +# veadk mpa create config (contains PG/model/OpenViking/Feishu secrets). +# Only the committed *.example.yaml template is allowed. +mpa-create.config.yaml +mpa-create.config*.yaml +!mpa-create.config.example.yaml +**/mpa-create.config.yaml +**/mpa-create.config*.yaml +!**/mpa-create.config.example.yaml + # PiAgent release archives are downloaded and verified during image builds. examples/piagent_with_mcp/vendor/pi-*.tar.gz +.gstack/ diff --git a/AGENTS.md b/AGENTS.md index 68b82a6e0..2045be95e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,61 @@ # Agent Instructions -- Do not use `codex/` as a branch name prefix in this repository. Use semantic branch prefixes such as `feat/`, `fix/`, `chore/`, or `docs/`; for example, `feat/pr-748-dev`. -- Before modifying frontend code, read `frontend/SPEC.md`. -- When the user describes a requirement, propose an implementation plan before changing code, and wait for the user to be satisfied before making edits. -- After creating or switching to a new branch, run `git pull` before development. Branch names must use semantic prefixes and reflect the requirement content. -- When committing, include only changes related to the current feature or fix. -- Before committing, fetch the latest remote code, rebase the branch onto it, then run pre-commit and unit tests. -- When execution hits a pitfall such as insufficient permissions or missing dependencies, ask the user whether to document that pitfall. +VeADK includes a Python SDK, CLI/runtime integrations, and AgentKit Studio with a TypeScript/React frontend and supporting Python services. Base decisions on the relevant implementation, tests, and existing contracts rather than assumptions from another repository. + +## Language and document ownership + +- Keep this repository-wide development standard in English. Use English for new code identifiers, comments, and docstrings; user-facing text follows the owning area's localization rules. +- Read [prd-spec/README.md](prd-spec/README.md) before creating or updating a change design. Every PRD/design must have complete English `.md` and Chinese `.zh.md` counterparts, sharing the same directory, date, and basename. +- Read [specs/README.md](specs/README.md) before defining or changing a component contract. Maintain each component under `specs//README.md` and `README.zh.md`. +- Keep both languages semantically equivalent and update them in the same change. Preserve identifiers, commands, examples, limits, and acceptance criteria across translations. Missing or conflicting counterparts block approval and delivery. +- PRDs own change-specific intent, scope, tasks, and acceptance; component specs own maintained responsibilities, interfaces, state, data, and failure semantics. Link them rather than duplicate contracts. +- Before modifying frontend code, read `frontend/SPEC.md`; also apply it to its supporting backend interfaces as specified there. It remains the Studio development standard and is not replaced or translated by this document. +- Preserve existing user-documentation conventions: the documentation site uses Chinese `.mdx` and English `.en.mdx`; bilingual module/example READMEs use English `README.md` and Chinese `README.zh.md`. + +## Required development workflow + +1. **Inspect and agree on scope.** Read the affected implementation, tests, and contracts. When the user describes a requirement, propose an implementation plan and wait for the user to be satisfied before making edits. Distinguish verified facts from assumptions. +2. **Design before implementation.** Features, refactors, and bugfixes require a bilingual PRD under `prd-spec/features/`, `prd-spec/refactors/`, or `prd-spec/bugfixes/`. Include background/evidence, goals, non-goals, scenarios, requirements, design, affected files, tasks, tests, risks, and acceptance criteria. +3. **Assess component contracts.** Create or update affected bilingual component specs for changes to ownership, APIs, configuration, state/data, events, permissions, security, compatibility, runtime behavior, or observability. Record a justified no-impact conclusion when no component contract changes. Do not backfill unrelated components. +4. **Clear design review.** Review PRD/spec consistency, feasibility, boundaries, errors, security, compatibility, testability, and bilingual equivalence. Use the available `review-spec` skill; if unavailable, perform and record the same review directly. Resolve blockers and record user approval before modifying production code or tests. A generic skill's Chinese-only default does not override this repository's bilingual rule. +5. **Implement against the approved design.** Map changes to requirements/tasks, write a failing regression or contract test first, then implement and refactor within scope. Contract or scope changes return to design review before implementation proceeds. +6. **Verify and review.** Run affected tests first, then required repository and component gates. Review both implementation correctness and edge cases, resource handling, security, test quality, and contract alignment. Fix blocking findings and rerun affected checks. +7. **Reconcile and deliver.** Synchronize both document languages, component contracts, affected user docs/examples, and required generated artifacts. Record actual verification commands, results, unverified areas, and remaining risks. All agreed tasks and acceptance criteria must be satisfied before declaring completion; deferred scope requires user agreement. + +Pure documentation, translation, or formatting changes that preserve runtime behavior and public contracts do not need a recursive PRD. They still require an agreed approach, applicable bilingual updates, and proportionate checks. This exception includes maintaining these development guidelines; it does not exempt code or behavioral changes. + +## Implementation boundaries + +- Make the smallest complete change that satisfies the approved requirement. Reuse existing modules and dependencies; avoid unrelated cleanup, speculative abstractions, and partially wired features. +- Treat public Python imports/signatures, CLI/configuration behavior, generated projects, Studio API types, and runtime/event contracts as consumer-facing boundaries. Breaking changes require explicit approval and a plan for affected callers and data; do not copy another project's blanket no-compatibility policy. +- Cover asynchronous cancellation, timeouts, late responses/events, terminal states, and resource cleanup where affected. Do not turn errors into silent success or empty results. +- Keep real credentials, signed URLs, personal data, and unredacted production logs out of source, design documents, reports, test fixtures, and generated artifacts. +- Isolate tests from real user state and external services by default. Real cloud/provider operations need explicit authorization and an isolated smoke/E2E procedure; report simulated and live verification separately. + +## Verification gates + +Use the repository-local Python environment and current `pyproject.toml`, `pytest.ini`, package scripts, and CI workflows as command references. Do not invent `make` targets, copy another repository's coverage thresholds, or treat an unavailable check as passing. + +| Change area | Required verification | +| --- | --- | +| Python SDK, CLI, and backend | Targeted tests via `uv run --extra dev pytest `; broader regression tests when shared contracts or dependencies are affected. | +| Default parallel Python regression | `uv run --extra dev pytest -n 2 -m "not codex_smoke and not piagent_smoke"`; adjust worker count to available resources and record the selection. Explicit smoke runs are separate. | +| Codex runtime smoke | When affected and the environment is prepared: `CODEX_RUN_SMOKE=1 uv run --extra dev pytest -m codex_smoke -p no:xdist -rs`. This test starts real processes and binds ports; never run it under parallel pytest. Verify it actually ran rather than skipped. | +| Studio frontend | `npm --prefix frontend test` and `npm --prefix frontend run build`, plus real browser checks of the normal flow and affected loading, empty, error, cancellation, retry, keyboard/IME, and narrow-window cases. Follow `frontend/SPEC.md` for additional checks and release asset requirements. | +| Studio backend or generated Python | Targeted Python tests plus Ruff and Pyright for changed Python files, as required by `frontend/SPEC.md`; verify generated code, dependencies, configuration, and deployment payloads together. | +| Harness/sidecar contracts | The affected checks and existing coverage thresholds in `.github/workflows/harness-sidecar-release-gate.yaml`, including `npm --prefix frontend run test:harness-sidecar-coverage` when frontend sidecar contracts change. | +| PRD/spec and guideline-only changes | Check paired-language completeness, contract/identifier consistency, relative links, referenced paths/commands, and diff whitespace. No runtime test result may be claimed from documentation checks. | +| User documentation site | The affected Markdown/MDX checks and build/type checks defined in `docs/package.json`; do not apply site-specific tooling to unrelated plain Markdown without checking its scope. | + +- Before committing, run `uv run --extra dev pre-commit run --all-files` and unit tests after synchronizing the branch as described below. Pre-commit includes Ruff and secret scanning; it does not replace browser, integration, or runtime verification. +- Prefer targeted checks during iteration. Expand to affected integration/E2E and regression gates for shared or high-risk changes and final delivery; explain any omitted required check. +- Record check outcomes as `pass`, `fail`, `blocked`, `not_run`, or `not_applicable`, with reasons for the last three. Include the tested revision/diff scope and execution date. Skipped tests do not prove the relevant behavior. +- Keep change-specific review and verification summaries in the bilingual PRD by default. Separate evidence artifacts are optional when useful; OpenSpec and a multi-report hierarchy are not prerequisites. + +## Git and execution safety + +- Do not use `codex/` as a branch name prefix. Use semantic prefixes such as `feat/`, `fix/`, `chore/`, or `docs/`, with a name reflecting the requirement. +- After creating or switching to a new branch, run `git pull` before development. If no upstream exists or synchronization fails, resolve the target explicitly rather than guessing or bypassing the requirement. +- Before committing, fetch the latest remote code, rebase the branch onto the intended remote base, then run pre-commit and unit tests. Preserve unrelated user changes and stop if safe synchronization requires a user decision. +- Commit only changes related to the current feature or fix. Commit, push, PR creation, publication, and deployment require user authorization; approval of a design does not authorize them. +- When execution hits a pitfall such as insufficient permissions or missing dependencies, ask the user whether to document that pitfall. Report blocked checks honestly and do not alter machine-global configuration or bypass gates to hide the issue. diff --git a/frontend/README.md b/frontend/README.md index b87f3c5c9..93bb29d4c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1047,3 +1047,33 @@ Sandbox。修改这些模板只需要更新 Studio,不需要重建镜像,也 模板中的新依赖不会自动安装,运行环境依赖仍由镜像管理。 其他地域需要显式设置 `STUDIO_WORKSPACE_IMAGE`,避免错误使用跨地域镜像。 + +### MPA Runtime scheduled tasks + +Select a connected cloud Runtime and open the Runtime scheduled tasks tab. +Studio uses gateway authentication to call `/api/v1/esa-cron-tasks`, without TOP +credential acquisition, enterprise UID configuration, JWT or user identity headers. +The MPA Runtime must have `DISABLE_JWT_AUTH=true` and support all-user read access +(commit `833c6bc` or later). Gateway authentication and Studio Runtime authorization +remain enforced. The operator must enable this mode on the Runtime explicitly; +Studio never changes the authentication setting. + +The list shows all users' tasks in the selected Runtime's configured store, with +search, pagination, prompt details, execution counts and success rate. Errors are +not rendered as empty results. No task mutations are performed. + +Run `npm run test:mpa-cron-coverage` and +`python -m pytest tests/frontend/server/test_mpa_cron.py --cov=frontend.server.mpa_cron --cov-branch --cov-fail-under=96` +for isolated client/server checks. + +### Runtime task management (2026-09-15) + +Runtime tasks now follow mono's list/calendar workflow: status filtering, creation, editing, copying, deletion, enable/disable, run now, and execution history. Select a cloud Runtime first. The server forwards the authenticated Studio user as `x-user-id`; with MPA JWT disabled, that user's task data remains isolated. No JWT input or TOP credential exchange is used. Enter the Runtime's Agent ID when creating a task. Complex Cron expressions display only the server's next execution in the calendar. Mutations and history use the existing MPA REST interfaces; Runtime upgrades and ADK session authentication are separate concerns. + +### A2A 长耗时请求 + +连接 A2A Runtime 后,对话会显示等待响应、排队或执行中的状态。收到有效状态后会继续等待最终回复,不因任务耗时超过 30 秒而自动中断。等待响应不代表 Runtime 已接受任务;错误或连接中断也不代表后台任务已取消,请先确认任务状态再重试创建等操作。 + +### Sandbox file downloads + +Assistant Markdown links under `/data/output/` or `/data/workspace/` appear as download buttons in conversation history and streaming messages. Studio uses the current Runtime and session through its authenticated proxy; files remain available only while that Sandbox and file exist. Legacy `` and `` payloads are hidden in conversation rendering. A failed download can be retried by clicking the button again. Run `npm run test:sandbox-download-coverage` for the focused regression suite. diff --git a/frontend/package.json b/frontend/package.json index 9500923c6..12ae05c85 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,8 @@ "build": "node scripts/build.mjs", "test": "node --test tests/*.test.mjs", "check:i18n": "node scripts/check-i18n.mjs", + "test:sandbox-download-coverage": "vitest run --coverage -c vitest.sandbox-download.config.ts", + "test:mpa-cron-coverage": "vitest run --coverage -c vitest.mpa-cron.config.ts", "test:webui-assets": "node scripts/verifyBuiltAssets.mjs", "test:visualizations": "node scripts/testVisualizationCompatibility.mjs", "test:harness-sidecar-coverage": "vitest run --coverage -c vitest.harness-sidecar.config.ts", diff --git a/frontend/server/mpa_cron.py b/frontend/server/mpa_cron.py new file mode 100644 index 000000000..720d363aa --- /dev/null +++ b/frontend/server/mpa_cron.py @@ -0,0 +1,182 @@ +"""User-scoped task management through authorized MPA Runtime connections.""" + +import asyncio +from collections.abc import Callable +from typing import Any + +import httpx +from fastapi import FastAPI, HTTPException, Path, Query, Request + + +async def task_request( + endpoint: str, + authorization: str, + user_id: str, + method: str, + suffix: str = "", + params: dict | None = None, + payload: dict | None = None, +) -> dict[str, Any]: + if not user_id: + raise HTTPException(401, "mpa_identity_required") + try: + async with httpx.AsyncClient(timeout=30, follow_redirects=False) as client: + response = await client.request( + method, + endpoint.rstrip("/") + "/api/v1/esa-cron-tasks" + suffix, + headers={"Authorization": authorization, "x-user-id": user_id}, + params=params, + json=payload, + ) + if not response.is_success: + raise HTTPException( + response.status_code if response.is_error else 502, + "mpa_tasks_failed", + ) + data = response.json() + if not isinstance(data, dict): + raise TypeError("Invalid task response") + return data + except (httpx.HTTPError, ValueError, TypeError) as error: + raise HTTPException(502, "mpa_upstream_failed") from error + + +def mount_routes( + app: FastAPI, + *, + authorize: Callable, + connection: Callable, + authorization: Callable, + region_for: Callable, + user_for: Callable, +) -> None: + async def send( + request, runtime_id, region, method, suffix="", params=None, payload=None + ): + region = region_for(region) + runtime = await asyncio.to_thread(authorize, request, runtime_id, region) + user_id = user_for(request) + endpoint, apikey, auth_type, _ = await asyncio.to_thread( + connection, runtime_id, region, runtime + ) + return await task_request( + endpoint, + authorization(request, apikey, auth_type), + user_id, + method, + suffix, + params, + payload, + ) + + async def body(request: Request, allowed: set[str]): + raw = await request.body() + if len(raw) > 32768: + raise HTTPException(413, "mpa_payload_too_large") + try: + payload = await request.json() + except ValueError as error: + raise HTTPException(422, "mpa_invalid_payload") from error + if not isinstance(payload, dict) or set(payload) - allowed: + raise HTTPException(422, "mpa_invalid_payload") + return payload + + fields = { + "name", + "agentId", + "prompt", + "enabled", + "schedule", + "delivery", + "jitterSeconds", + "timeoutSeconds", + } + + @app.get("/web/mpa-cron/{runtime_id}") + async def get_tasks( + request: Request, + runtime_id: str, + region: str, + offset: int = Query(0, ge=0), + query: str = Query("", max_length=200), + ): + return await send( + request, + runtime_id, + region, + "GET", + params={ + "includeDisabled": "true", + "limit": 20, + "offset": offset, + "query": query, + }, + ) + + @app.post("/web/mpa-cron/{runtime_id}") + async def create_task(request: Request, runtime_id: str, region: str): + return await send( + request, + runtime_id, + region, + "POST", + payload=await body(request, fields | {"clientToken"}), + ) + + @app.post("/web/mpa-cron/{runtime_id}/{task_id}") + async def update_task( + request: Request, + runtime_id: str, + region: str, + task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"), + ): + return await send( + request, + runtime_id, + region, + "POST", + "/" + task_id, + payload=await body(request, fields | {"expectedVersion"}), + ) + + @app.delete("/web/mpa-cron/{runtime_id}/{task_id}") + async def delete_task( + request: Request, + runtime_id: str, + region: str, + task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"), + ): + return await send(request, runtime_id, region, "DELETE", "/" + task_id) + + @app.post("/web/mpa-cron/{runtime_id}/{task_id}/run") + async def run_task( + request: Request, + runtime_id: str, + region: str, + task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"), + ): + return await send( + request, + runtime_id, + region, + "POST", + "/" + task_id + "/run", + payload=await body(request, {"clientToken", "mode"}), + ) + + @app.get("/web/mpa-cron/{runtime_id}/{task_id}/runs") + async def get_runs( + request: Request, + runtime_id: str, + region: str, + task_id: str = Path(pattern=r"^[A-Za-z0-9_-]+$"), + offset: int = Query(0, ge=0), + ): + return await send( + request, + runtime_id, + region, + "GET", + "/" + task_id + "/runs", + params={"offset": offset, "limit": 20}, + ) diff --git a/frontend/server/runtime_logs.py b/frontend/server/runtime_logs.py index ec3ce73dd..db53457b7 100644 --- a/frontend/server/runtime_logs.py +++ b/frontend/server/runtime_logs.py @@ -277,10 +277,11 @@ async def read_logs( runtime_types.GetRuntimeInstanceLogsRequest( RuntimeId=runtime_id, InstanceName=instance_name, - Limit=500, + Limit=1000, ), ) - return self._sanitize(str(response.logs or "")) + sanitized = self._sanitize(str(response.logs or "")) + return "\n".join(sanitized.splitlines()[-1000:]) async def snapshot( self, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 875a5d50d..89900c33d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { SandboxFileContext } from "./ui/SandboxFileLink"; import { useCallback, useEffect, @@ -29,6 +30,7 @@ import { downloadArtifact, previewArtifact, getAgentInfo, + getTurnControl, getAutomaticEvaluationStatuses, getSessionTrace, getSession, @@ -43,6 +45,7 @@ import { prepareSessionEnvironmentMounts, runSseIncompleteResponseError, runSSE, + controlTurn, refreshAgentFeedbackCases, submitIssueFeedback, submitMessageFeedback, @@ -66,10 +69,13 @@ import { type StudioAccess, type StudioEnvironment, type StudioWorkspace, + type TurnControlState, + TurnControlConflictError, type UiConfig, type UiFeatures, } from "./adk/client"; import type { RuntimeLogTarget } from "./adk/runtimeLogs"; +import type { SelectedSkill } from "./create/skills/types"; import { addTokenUsage, aggregateTokenUsage, @@ -297,6 +303,14 @@ function issueFeedbackModuleForPage(page: string): IssueFeedbackModule { return "other"; } +function isOptionalStudioStorageUnavailable(cause: unknown): boolean { + const message = cause instanceof Error ? cause.message : String(cause ?? ""); + return message.includes("HTTP 503") && ( + message.includes("未配置持久化存储") || + message.includes("storage is not configured") + ); +} + interface NewChatCapabilitiesState { agentId?: string; ready?: boolean; @@ -400,8 +414,6 @@ const DRAFT_AUTOSAVE_DELAY_MS = 600; const AUTO_EVALUATION_RUNNING_POLL_MS = 1_000; const AUTO_EVALUATION_RETRY_POLL_MS = 5_000; const AUTO_EVALUATION_MIN_PENDING_POLL_MS = 500; -const EMPTY_STRING_SET: Set = new Set(); -const EMPTY_STRING_ARR: string[] = []; const ENVIRONMENT_STUDIO_TOOL_IDS = [ "list_envs", "get_env_manifest", @@ -409,6 +421,7 @@ const ENVIRONMENT_STUDIO_TOOL_IDS = [ "delegate_to_codex_sandbox", ] as const; const SESSION_ENVIRONMENT_STORAGE_KEY = "veadk.sessionEnvironmentMounts.v1"; +const SESSION_SKILL_STORAGE_KEY = "veadk.sessionSkillMounts.v1"; interface StoredSessionEnvironmentState { mounts: Record; @@ -484,6 +497,43 @@ function persistSessionEnvironmentState( } } +function loadStoredSessionSkills(): Record { + if (typeof localStorage === "undefined") return {}; + try { + const raw = JSON.parse(localStorage.getItem(SESSION_SKILL_STORAGE_KEY) ?? "{}"); + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + return Object.fromEntries( + Object.entries(raw as Record).slice(-200).map(([key, value]) => [ + key, + Array.isArray(value) + ? value.slice(0, 20).filter((item): item is SelectedSkill => ( + Boolean(item) + && typeof item === "object" + && !Array.isArray(item) + && (item as SelectedSkill).source === "skillspace" + && typeof (item as SelectedSkill).name === "string" + && typeof (item as SelectedSkill).folder === "string" + && typeof (item as SelectedSkill).skillSpaceId === "string" + && typeof (item as SelectedSkill).skillId === "string" + && typeof (item as SelectedSkill).version === "string" + )) + : [], + ]), + ); + } catch { + return {}; + } +} + +function persistSessionSkills(skills: Record) { + if (typeof localStorage === "undefined") return; + try { + localStorage.setItem(SESSION_SKILL_STORAGE_KEY, JSON.stringify(skills)); + } catch { + // Storage can be unavailable in private or quota-restricted browsers. + } +} + function emptyInvocation(): FrontendInvocation { return { skills: [] }; } @@ -1440,17 +1490,20 @@ export default function App() { const [studioToolCapabilities, setStudioToolCapabilities] = useState(null); const [studioToolsLoading, setStudioToolsLoading] = useState(false); - const [studioToolsError, setStudioToolsError] = useState(""); + const [selectedCronRuntime, setSelectedCronRuntime] = useState<{ + runtimeId: string; name: string; region: string; + } | undefined>(); + useEffect(() => setSelectedCronRuntime(undefined), [appName]); const [draftStudioRuntime, setDraftStudioRuntime] = useState<{ appName: string; runtimeId: string; name: string; region: string; } | null>(null); - const [draftStudioToolIds, setDraftStudioToolIds] = useState([]); - const [studioToolIdsBySession, setStudioToolIdsBySession] = useState< - Record - >({}); + const [sessionSkillsBySession, setSessionSkillsBySession] = useState< + Record + >(() => loadStoredSessionSkills()); + useEffect(() => persistSessionSkills(sessionSkillsBySession), [sessionSkillsBySession]); const [sessionEnvironments, setSessionEnvironments] = useState([]); const [sessionWorkspaces, setSessionWorkspaces] = useState([]); const [sessionEnvironmentsLoading, setSessionEnvironmentsLoading] = useState(false); @@ -1478,6 +1531,12 @@ export default function App() { Record >({}); const [agentInfo, setAgentInfo] = useState(null); + const [turnControlBySession, setTurnControlBySession] = useState>({}); + const turnControlBySessionRef = useRef>({}); + const [turnControlBusy, setTurnControlBusy] = useState(false); + const [selectedModelBySession, setSelectedModelBySession] = useState< + Record + >({}); const [agentInfoRefreshKey, setAgentInfoRefreshKey] = useState(0); const [capabilitiesLoading, setCapabilitiesLoading] = useState(false); const removedAttachmentIdsRef = useRef>(new Set()); @@ -1823,20 +1882,6 @@ export default function App() { const [uiConfigLoaded, setUiConfigLoaded] = useState(false); const [localMode, setLocalMode] = useState(false); const [loadingSession, setLoadingSession] = useState(false); - // The executing sub-agent (ADK event.author) and everyone who emitted this - // turn — PER SESSION, so each session's topology highlights its own stream. - const [activeAgentBySession, setActiveAgentBySession] = useState< - Record - >({}); - const [seenAgentsBySession, setSeenAgentsBySession] = useState< - Record> - >({}); - // The current delegation chain (root → … → executing agent) per session, - // built from event.actions.transfer_to_agent / end_of_agent. - const [execPathBySession, setExecPathBySession] = useState< - Record - >({}); - // Everything the view needs for the ACTIVE session, derived from the // per-session maps above. const busy = streamingSids.has(sessionId); @@ -1961,9 +2006,6 @@ export default function App() { if (timer !== undefined) window.clearTimeout(timer); }; }, [sandboxBusy, sandboxSession?.id]); - const activeAgent = activeAgentBySession[sessionId] ?? ""; - const seenAgents = seenAgentsBySession[sessionId] ?? EMPTY_STRING_SET; - const execPath = execPathBySession[sessionId] ?? EMPTY_STRING_ARR; const rootCapabilityNode = agentInfo?.graph; const rootAgentNames = [ agentInfo?.name, @@ -1986,11 +2028,13 @@ export default function App() { tools: [ ...new Set([ ...(rootCapabilityNode?.tools ?? agentInfo.tools), - ...(sessionId - ? (studioToolIdsBySession[ - studioToolSelectionKey(appName, userId, sessionId) - ] ?? []) - : draftStudioToolIds), + ...(sessionId && ( + environmentMountsBySession[ + studioToolSelectionKey(appName, userId, sessionId) + ] ?? [] + ).length > 0 + ? [...ENVIRONMENT_STUDIO_TOOL_IDS] + : []), ]), ], skills: rootCapabilityNode?.skills ?? agentInfo.skills, @@ -2043,41 +2087,6 @@ export default function App() { } } - // Apply a stream event's control-flow signals to a session's live state: - // author = who's executing now; transfer_to_agent pushes the delegation - // chain; end_of_agent / escalate pops it. `author` always wins for highlight. - const applyStreamSignals = (sid: string, ev: AdkEvent) => { - const who = ev.author && ev.author !== "user" ? ev.author : undefined; - if (who) { - setActiveAgentBySession((m) => ({ ...m, [sid]: who })); - setSeenAgentsBySession((m) => ({ - ...m, - [sid]: new Set(m[sid] ?? []).add(who), - })); - // Seed the path with the entry (root) agent on the first event. - setExecPathBySession((m) => - m[sid]?.length ? m : { ...m, [sid]: [who] }, - ); - } - const transferTo = - ev.actions?.transferToAgent ?? ev.actions?.transfer_to_agent; - if (transferTo) { - setExecPathBySession((m) => { - const cur = m[sid] ?? []; - return cur[cur.length - 1] === transferTo - ? m - : { ...m, [sid]: [...cur, transferTo] }; - }); - } - const ended = - ev.actions?.endOfAgent ?? ev.actions?.end_of_agent ?? ev.actions?.escalate; - if (ended) { - setExecPathBySession((m) => { - const cur = m[sid] ?? []; - return cur.length <= 1 ? m : { ...m, [sid]: cur.slice(0, -1) }; - }); - } - }; const [createView, setCreateView] = useState(loadView); const [deploymentTasks, setDeploymentTasks] = useState< DeploymentTaskUpdate[] @@ -4740,7 +4749,6 @@ export default function App() { setInitializingSession(false); setPendingTurns([]); setInvocation(emptyInvocation()); - setDraftStudioToolIds([]); discardDraftAttachments(attachments); setAttachments([]); if (abandonedSession) void abandonDraftSession(abandonedSession); @@ -5095,24 +5103,100 @@ export default function App() { ); } + const activeTurnControl = sessionId ? turnControlBySession[sessionId] ?? null : null; + const activeTurnIsControllable = Boolean( + activeTurnControl?.allowedActions.length + || ["running", "pausing", "paused", "resuming", "interrupting", "cancelling"] + .includes(activeTurnControl?.state ?? ""), + ); + + async function applyTurnControl(action: "pause" | "resume") { + if (!sessionId || !appName || !activeTurnControl) return; + setTurnControlBusy(true); + try { + const next = await controlTurn( + appName, sessionId, action, activeTurnControl.generation, + ); + if (action === "resume" && next.resumeDisposition === "new_turn_required") { + const originalTask = typeof next.checkpoint?.originalTask === "string" + ? next.checkpoint.originalTask.trim() + : ""; + streamAbortsRef.current.get(sessionId)?.abort(); + await send( + [next.continuationPrompt, originalTask && `Original task: ${originalTask}`] + .filter(Boolean) + .join("\n\n"), + [], + emptyInvocation(), + "composer", + undefined, + true, + ); + return; + } + setTurnControlBySession((current) => ({ ...current, [sessionId]: next })); + turnControlBySessionRef.current = { + ...turnControlBySessionRef.current, + [sessionId]: next, + }; + } catch (cause) { + if (cause instanceof TurnControlConflictError) { + const next = cause.authoritativeState; + setTurnControlBySession((current) => ({ ...current, [sessionId]: next })); + turnControlBySessionRef.current = { + ...turnControlBySessionRef.current, + [sessionId]: next, + }; + return; + } + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setTurnControlBusy(false); + } + } + async function send( text: string, atts: Attachment[] = [], selectedInvocation: FrontendInvocation = emptyInvocation(), messageSource: AgentMessageSource = "composer", selectedPlatformTools?: readonly string[], + allowWhileBusy = false, ) { // `busy` here = the CURRENT session is already streaming (can't double-send // to it). Other sessions can stream concurrently. if ( (!text.trim() && atts.length === 0) || - conversationBusy || + (!allowWhileBusy && conversationBusy) || !appName || !userId ) return; + const selectableModels = agentInfo?.selectableModels ?? []; + const modelSelectionKey = sessionId || `new:${appName}`; + const selectedModel = selectedModelBySession[modelSelectionKey] || ""; + const requestedModel = selectableModels.length > 1 + ? selectableModels.includes(selectedModel) + ? selectedModel + : agentInfo?.model || selectableModels[0] || "" + : ""; + const mountedSkillInvocation = { + ...selectedInvocation, + skills: [ + ...selectedInvocation.skills, + ...selectedSessionSkills.map((skill) => ({ + name: skill.name, + description: skill.description ?? "", + skillSpaceId: skill.skillSpaceId, + skillId: skill.skillId, + version: skill.version, + })), + ].filter((skill, index, values) => ( + values.findIndex((candidate) => candidate.name === skill.name) === index + )), + }; setError(""); const createsSession = !sessionId; - let platformTools = [...(selectedPlatformTools ?? selectedStudioToolIds)]; + let platformTools = [...(selectedPlatformTools ?? environmentStudioToolIds)]; const environmentMounts = createsSession ? [] : environmentMountsBySession[ @@ -5134,8 +5218,8 @@ export default function App() { : null; const userBlocks: Turn["blocks"] = []; - if (selectedInvocation.skills.length > 0 || selectedInvocation.targetAgent) { - userBlocks.push({ kind: "invocation", value: selectedInvocation }); + if (mountedSkillInvocation.skills.length > 0 || mountedSkillInvocation.targetAgent) { + userBlocks.push({ kind: "invocation", value: mountedSkillInvocation }); } if (atts.length) userBlocks.push({ @@ -5191,6 +5275,9 @@ export default function App() { if (selectedTask) { const requiredTools = NEW_CHAT_TASK_TOOLS[selectedTask]; const agentTools = new Set(agentInfo?.tools ?? []); + const availableStudioToolIds = new Set( + studioToolCapabilities?.tools.map((tool) => tool.id) ?? [], + ); const availableTools = new Set([ ...agentTools, ...(studioToolRuntime ? availableStudioToolIds : []), @@ -5231,15 +5318,15 @@ export default function App() { createsSession ? optimisticTurns : [...current, ...optimisticTurns], ); if (createsSession) { - if (studioToolRuntime) { - const key = studioToolSelectionKey(appName, userId, sid); - setStudioToolIdsBySession((current) => ({ + viewSidRef.current = sid; + setSessionId(sid); + if (requestedModel && createsSession) { + setSelectedModelBySession((current) => ({ ...current, - [key]: [...platformTools], + [sid]: requestedModel, + [modelSelectionKey]: "", })); } - viewSidRef.current = sid; - setSessionId(sid); setPendingTurns([]); setInitializingSession(false); } @@ -5248,13 +5335,20 @@ export default function App() { const ctrl = new AbortController(); streamAbortsRef.current.set(sid, ctrl); setStreaming(sid, true); + if (agentInfo?.turnLifecycleControl && currentRuntime) { + setTurnControlBySession((current) => { + if (!current[sid]) return current; + const next = { ...current }; + delete next[sid]; + return next; + }); + const nextTurnControls = { ...turnControlBySessionRef.current }; + delete nextTurnControls[sid]; + turnControlBySessionRef.current = nextTurnControls; + } startStreamPresentation(sid); viewSidRef.current = sid; - setActiveAgentBySession((m) => ({ ...m, [sid]: "" })); - setSeenAgentsBySession((m) => ({ ...m, [sid]: new Set() })); - setExecPathBySession((m) => ({ ...m, [sid]: [] })); - const eventProjector = createAssistantEventProjector( `${sid}-${crypto.randomUUID()}`, optimisticAssistantTurn, @@ -5269,8 +5363,9 @@ export default function App() { userId, sessionId: sid, text, + modelId: requestedModel, attachments: atts, - invocation: selectedInvocation, + invocation: mountedSkillInvocation, platformTools: studioToolRuntime ? platformTools : undefined, environmentMounts: studioToolRuntime && environmentMounts.length > 0 ? environmentMounts @@ -5291,8 +5386,6 @@ export default function App() { if (viewSidRef.current === sid) setError(errMsg); break; } - // Live topology: author + transfer/end signals, keyed by session. - applyStreamSignals(sid, event); addTokenUsageFor(appName, sid, event); const projection = eventProjector.project(event); if (projection.ignored) continue; @@ -5368,11 +5461,11 @@ export default function App() { ) { setInput((current) => current.trim() ? current : text); } - if (streamAbortsRef.current.get(sid) === ctrl) streamAbortsRef.current.delete(sid); - setStreaming(sid, false); - finishStreamPresentation(sid); - setActiveAgentBySession((m) => ({ ...m, [sid]: "" })); - setExecPathBySession((m) => ({ ...m, [sid]: [] })); + if (streamAbortsRef.current.get(sid) === ctrl) { + streamAbortsRef.current.delete(sid); + setStreaming(sid, false); + finishStreamPresentation(sid); + } } } @@ -5433,8 +5526,8 @@ export default function App() { studioToolSelectionKey(appName, userId, sid) ] ?? []; const resumedPlatformTools = environmentMounts.length > 0 - ? [...new Set([...selectedStudioToolIds, ...ENVIRONMENT_STUDIO_TOOL_IDS])] - : selectedStudioToolIds; + ? [...ENVIRONMENT_STUDIO_TOOL_IDS] + : []; const eventProjector = createAssistantEventProjector( `${sid}-${crypto.randomUUID()}`, lastTurn?.role === "assistant" @@ -5471,7 +5564,6 @@ export default function App() { if (viewSidRef.current === sid) setError(errMsg); break; } - applyStreamSignals(sid, event); addTokenUsageFor(appName, sid, event); const projection = eventProjector.project(event); if (projection.ignored) continue; @@ -5514,8 +5606,6 @@ export default function App() { if (streamAbortsRef.current.get(sid) === ctrl) streamAbortsRef.current.delete(sid); setStreaming(sid, false); finishStreamPresentation(sid); - setActiveAgentBySession((m) => ({ ...m, [sid]: "" })); - setExecPathBySession((m) => ({ ...m, [sid]: [] })); } } @@ -5536,6 +5626,54 @@ export default function App() { region: currentConn.region, } : undefined; + useEffect(() => { + if ( + !sessionId || + !appName || + !currentRuntime || + !agentInfo?.turnLifecycleControl + ) return; + let cancelled = false; + const refresh = () => { + void getTurnControl(appName, sessionId) + .then((state) => { + if (!cancelled) { + setTurnControlBySession((current) => ( + current[sessionId]?.taskId === state.taskId + && current[sessionId]?.state === state.state + && current[sessionId]?.generation === state.generation + && current[sessionId]?.allowedActions.join("\0") + === state.allowedActions.join("\0") + ? current + : { ...current, [sessionId]: state } + )); + turnControlBySessionRef.current = { + ...turnControlBySessionRef.current, + [sessionId]: state, + }; + } + }) + .catch(() => { + if (!cancelled && !streamingSids.has(sessionId)) { + setTurnControlBySession((current) => { + const next = { ...current }; + delete next[sessionId]; + return next; + }); + const next = { ...turnControlBySessionRef.current }; + delete next[sessionId]; + turnControlBySessionRef.current = next; + } + }); + }; + refresh(); + const timer = window.setInterval(() => { + const current = turnControlBySessionRef.current[sessionId]; + if (current && ["completed", "failed", "rejected", "cancelled", "canceled", "interrupted", "orphaned"].includes(current.state)) return; + refresh(); + }, 1000); + return () => { cancelled = true; window.clearInterval(timer); }; + }, [agentInfo?.turnLifecycleControl, appName, currentRuntime, sessionId]); const selectedDraftStudioRuntime = draftStudioRuntime?.appName === appName ? draftStudioRuntime : undefined; // Local Agents execute through this Studio process, so use the synthetic @@ -5554,7 +5692,7 @@ export default function App() { useEffect(() => { let cancelled = false; setStudioToolCapabilities(null); - setStudioToolsError(""); + setSessionEnvironmentsError(""); if ( authStatus !== "authenticated" || !access || @@ -5575,7 +5713,7 @@ export default function App() { }) .catch((cause) => { if (cancelled) return; - setStudioToolsError( + setSessionEnvironmentsError( cause instanceof Error ? cause.message : appText("errors.localToolsLoadFailed"), ); }) @@ -5601,11 +5739,23 @@ export default function App() { setSessionEnvironmentsLoading(true); setSessionEnvironmentsError(""); try { - const [items, workspaces] = await Promise.all([ + const environmentResults = await Promise.allSettled([ listEnvironments(controller.signal), listWorkspaces(controller.signal), ]); if (controller.signal.aborted) return; + const items = environmentResults[0].status === "fulfilled" + ? environmentResults[0].value + : []; + const workspaces = environmentResults[1].status === "fulfilled" + ? environmentResults[1].value + : []; + const environmentErrors = environmentResults.flatMap((result) => + result.status === "rejected" && + !isOptionalStudioStorageUnavailable(result.reason) + ? [result.reason instanceof Error ? result.reason.message : String(result.reason)] + : [] + ); const availableEnvironments = items.filter((environment) => ["aio-sandbox", "codex-sandbox"].includes(environment.baseEnvironment) && environment.latestVersion?.status === "available" && @@ -5623,6 +5773,7 @@ export default function App() { // workspaces disappear from both the picker and existing Session mounts. setSessionEnvironments(availableEnvironments); setSessionWorkspaces(workspaces); + setSessionEnvironmentsError(environmentErrors.join("\n")); setEnvironmentMountsBySession((current) => Object.fromEntries( Object.entries(current).map(([key, selections]) => [ key, @@ -5748,49 +5899,29 @@ export default function App() { const activeStudioToolSelectionKey = sessionId ? studioToolSelectionKey(appName, userId, sessionId) : ""; - const storedStudioToolIds = sessionId - ? (studioToolIdsBySession[activeStudioToolSelectionKey] ?? []) - : draftStudioToolIds; const allStudioToolIds = new Set( studioToolCapabilities?.tools.map((tool) => tool.id) ?? [], ); - const availableStudioToolIds = new Set( - [...allStudioToolIds].filter((toolId) => !agentInfo?.tools.includes(toolId)), - ); const selectedEnvironmentMounts = sessionId ? environmentMountsBySession[activeStudioToolSelectionKey] ?? [] : []; - const selectedEnvironmentWorkspaceIds = sessionId - ? environmentWorkspaceIdsBySession[activeStudioToolSelectionKey] ?? [] - : []; const canMountSessionEnvironment = ENVIRONMENT_STUDIO_TOOL_IDS.every((toolId) => allStudioToolIds.has(toolId) ); - const selectedStudioToolIds = [...new Set([ - ...storedStudioToolIds.filter((toolId) => availableStudioToolIds.has(toolId)), - ...(selectedEnvironmentMounts.length > 0 && canMountSessionEnvironment - ? [...ENVIRONMENT_STUDIO_TOOL_IDS] - : []), - ])]; - const visibleStudioTools = studioToolCapabilities?.tools.filter((tool) => - !ENVIRONMENT_STUDIO_TOOL_IDS.includes( - tool.id as (typeof ENVIRONMENT_STUDIO_TOOL_IDS)[number], - ) || selectedEnvironmentMounts.length > 0 - ) ?? []; - const updateSelectedStudioToolIds = (selectedIds: string[]) => { - const next = [...new Set([ - ...selectedIds, - ...(selectedEnvironmentMounts.length > 0 ? [...ENVIRONMENT_STUDIO_TOOL_IDS] : []), - ])].filter((toolId) => - availableStudioToolIds.has(toolId), - ); - if (!sessionId) { - setDraftStudioToolIds(next); - return; - } - setStudioToolIdsBySession((current) => ({ + const environmentStudioToolIds = selectedEnvironmentMounts.length > 0 && canMountSessionEnvironment + ? [...ENVIRONMENT_STUDIO_TOOL_IDS] + : []; + const selectedSessionSkills = sessionId + ? sessionSkillsBySession[activeStudioToolSelectionKey] ?? [] + : []; + const selectedEnvironmentWorkspaceIds = sessionId + ? environmentWorkspaceIdsBySession[activeStudioToolSelectionKey] ?? [] + : []; + const updateSelectedSessionSkills = (skills: SelectedSkill[]) => { + if (!sessionId) return; + setSessionSkillsBySession((current) => ({ ...current, - [activeStudioToolSelectionKey]: next, + [activeStudioToolSelectionKey]: skills, })); }; const updateSelectedEnvironments = async ( @@ -5829,26 +5960,8 @@ export default function App() { ...current, [activeStudioToolSelectionKey]: workspaceIds, })); - setStudioToolIdsBySession((current) => { - const selectedIds = current[activeStudioToolSelectionKey] ?? []; - return { - ...current, - [activeStudioToolSelectionKey]: selections.length > 0 - ? [...new Set([...selectedIds, ...ENVIRONMENT_STUDIO_TOOL_IDS])] - : selectedIds.filter((toolId) => !ENVIRONMENT_STUDIO_TOOL_IDS.includes( - toolId as (typeof ENVIRONMENT_STUDIO_TOOL_IDS)[number], - )), - }; - }); }; - const studioToolsUnavailableReason = studioToolsError - ? studioToolsError - : studioToolCapabilities && !studioToolCapabilities.enabled - ? t("errors.localBffToolsNotConfigured") - : studioToolCapabilities && !studioToolCapabilities.supported - ? t("errors.runtimeBffToolsDisabled") - : ""; const sessionEnvironmentsUnavailableReason = sessionEnvironmentsError || (!studioToolsLoading && studioToolCapabilities && !canMountSessionEnvironment ? t("errors.sandboxToolsUnavailable") @@ -6145,6 +6258,7 @@ export default function App() { .filter((status) => status.state === "running") .map((status) => status.sessionId) ?? [], )); + startNewChat(); setAppName(id); exitAgentDetailContext(); setFocusedDeploymentTaskId(""); @@ -6157,7 +6271,6 @@ export default function App() { setIntelligentDeployment(null); setWorkspaceView(false); setEnvironmentView(false); - startNewChat(); }; const openIntelligentDeploymentChat = async (agentId: string) => { @@ -6220,6 +6333,10 @@ export default function App() { } = {}, ) => { if (!agent.runtime) return; + // Task reads do not depend on the Runtime's chat/session protocol. + setSelectedCronRuntime({ + runtimeId: agent.runtime.runtimeId, name: agent.name, region: agent.runtime.region, + }); try { const agentId = await connectRuntimeForUser( agent, @@ -6836,11 +6953,11 @@ export default function App() { atts, selectedInvocation, "composer", - selectedStudioToolIds, + undefined, ); releaseAttachmentPreviews(atts); }} - onStop={busy ? stopCurrentGeneration : undefined} + onStop={busy && !agentInfo?.turnLifecycleControl ? stopCurrentGeneration : undefined} disabled={ sandboxSession ? false @@ -6868,6 +6985,23 @@ export default function App() { modelName={ modelNameFromRuntime(agentInfo?.model) || activeTokenUsage.modelName } + selectableModels={agentInfo?.selectableModels} + selectedModel={ + agentInfo?.selectableModels?.includes( + selectedModelBySession[sessionId || `new:${appName}`] || "", + ) + ? selectedModelBySession[sessionId || `new:${appName}`] + : agentInfo?.model || agentInfo?.selectableModels?.[0] || "" + } + onSelectedModelChange={(model) => { + setSelectedModelBySession((current) => ({ + ...current, + [sessionId || `new:${appName}`]: model, + })); + }} + turnControl={activeTurnIsControllable ? activeTurnControl : null} + turnControlBusy={turnControlBusy} + onTurnControl={(action) => void applyTurnControl(action)} tokenUsage={activeTokenUsage} systemTokenEstimate={systemTokenEstimate} allowAttachments={!sandboxSession} @@ -7064,7 +7198,7 @@ export default function App() { setCreateView("workspace"); } : undefined} /> ) : cronJobsView ? ( - + ) : applicationsView === "coding-agents" ? ( setApplicationsView("catalog")} @@ -7819,35 +7953,37 @@ export default function App() { )} {pending ? ( - turnIsStreaming ? : null + turnIsStreaming ? : null ) : ( <> - completeStreamPresentation(sessionId) - : undefined - } - onAction={onAction} - onAuth={onAuth} - onArtifactDownload={(filename, version) => - downloadArtifact(appName, userId, sessionId, filename, version) - } - onArtifactPreview={(filename, version) => - previewArtifact(appName, userId, sessionId, filename, version) - } - onResolveDelivery={resolveIntelligentDelivery} - onResolveDeliveryComparison={resolveIntelligentDeliveryComparison} - onDownloadDelivery={downloadIntelligentDelivery} - onDeployDelivery={setIntelligentDeployment} - onBranchSelect={(branch) => { - setInput(t("conversation.continueBranch", { branch: branch.label })); - }} - /> + + completeStreamPresentation(sessionId) + : undefined + } + onAction={onAction} + onAuth={onAuth} + onArtifactDownload={(filename, version) => + downloadArtifact(appName, userId, sessionId, filename, version) + } + onArtifactPreview={(filename, version) => + previewArtifact(appName, userId, sessionId, filename, version) + } + onResolveDelivery={resolveIntelligentDelivery} + onResolveDeliveryComparison={resolveIntelligentDeliveryComparison} + onDownloadDelivery={downloadIntelligentDelivery} + onDeployDelivery={setIntelligentDeployment} + onBranchSelect={(branch) => { + setInput(t("conversation.continueBranch", { branch: branch.label })); + }} + /> + {/* Finalized turn that produced no visible answer (e.g. only thinking + an empty A2UI surface) — show a fallback note. */} {!turnIsStreaming && !turnHasVisibleContent(turn) && ( @@ -7982,23 +8118,10 @@ export default function App() { {!sandboxSession && ( 0 - ? ENVIRONMENT_STUDIO_TOOL_IDS - : []} - studioToolsLoading={studioToolsLoading} - studioToolsDisabled={conversationBusy} - studioToolsUnavailableReason={studioToolsUnavailableReason} - onStudioToolsChange={ - studioToolRuntime ? updateSelectedStudioToolIds : undefined - } + selectedSessionSkills={selectedSessionSkills} + onSessionSkillsChange={sessionId ? updateSelectedSessionSkills : undefined} environments={sessionEnvironments} workspaces={sessionWorkspaces} selectedEnvironments={selectedEnvironmentMounts} diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index ce54146b0..11158c6b4 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -1,3 +1,4 @@ +import { fetchMpaCronTasks, manageMpaTask, fetchMpaRuns, type MpaRuntime } from "./mpaCronTasks"; // Thin client for the Google ADK API server (the same server `veadk frontend` // launches). Uses relative URLs so it works same-origin in production and via // the Vite dev proxy in development. @@ -91,6 +92,8 @@ export interface AdkEvent { timestamp?: number; usageMetadata?: AdkUsage; usage_metadata?: AdkUsage; + customMetadata?: Record; + custom_metadata?: Record; // Set when the model/run fails; /run_sse emits it as a `data: {"error": ...}` // frame (also seen as errorMessage / error_message). error?: string; @@ -1203,6 +1206,19 @@ function decodeArtifactData(value: string): Uint8Array { return bytes; } +export async function fetchSessionFile( + appName: string, + sessionId: string, + path: string, + signal: AbortSignal, +): Promise { + const { ep } = resolve(appName); + const url = `/api/v1/sessions/${encodeURIComponent(sessionId)}/files/download?path=${encodeURIComponent(path)}`; + const response = await apiFetch(url, { signal }, ep, TRANSFER_REQUEST_TIMEOUT_MS); + if (!response.ok) throw new Error(`SESSION_FILE_HTTP_${response.status}`); + return response.blob(); +} + export async function downloadArtifact( appName: string, userId: string, @@ -1466,10 +1482,16 @@ export interface AgentTarget { } export interface FrontendInvocation { - skills: AgentSkill[]; + skills: SessionSkillSelection[]; targetAgent?: AgentTarget; } +export interface SessionSkillSelection extends AgentSkill { + skillSpaceId?: string; + skillId?: string; + version?: string; +} + /** Introspected metadata for an agent app, served locally or by Agent Server. */ export interface AgentInfo { /** Real ADK app id used in runtime proxy paths; display names may differ. */ @@ -1478,6 +1500,9 @@ export interface AgentInfo { description: string; type?: AgentNodeType; model: string; + selectableModels?: string[]; + turnLifecycleControl?: TurnLifecycleCapability; + resourceTopology?: ResourceTopology; tools: string[]; skills: AgentSkill[]; /** False when an older Agent Server omits Skill introspection entirely. */ @@ -1518,6 +1543,9 @@ async function fetchAgentInfo( description: info.description ?? "", type: info.type, model: info.model ?? "", + selectableModels: info.selectableModels ?? [], + turnLifecycleControl: info.turnLifecycleControl, + resourceTopology: info.resourceTopology, tools: info.tools ?? [], skillsPreviewSupported: Array.isArray(info.skills), skills: info.skills ?? [], @@ -1529,6 +1557,82 @@ async function fetchAgentInfo( }; } +export type TurnControlAction = "pause" | "resume"; + +export interface TurnLifecycleCapability { + actions: TurnControlAction[]; + pauseMode: "cooperative-safe-point" | string; + processReplacementResume: boolean; +} + +export interface ResourceTopology { + nodes: Array<{ id: string; kind: string; name: string; status: string; resourceId?: string }>; + edges: Array<{ source: string; target: string; relation: string }>; +} + +export interface TurnControlState { + taskId: string; + state: string; + generation: number; + desiredState?: string | null; + safePoint?: string | null; + checkpoint?: Record | null; + allowedActions: TurnControlAction[]; + idempotentReplay: boolean; + pausedAt?: string | null; + resumableUntil?: string | null; + resumeDisposition?: "same_turn" | "new_turn_required"; + continuationPrompt?: string | null; +} + +export class TurnControlConflictError extends Error { + constructor(readonly authoritativeState: TurnControlState) { + super("Turn state changed before the control request completed"); + this.name = "TurnControlConflictError"; + } +} + +export async function getTurnControl( + appName: string, + sessionId: string, +): Promise { + const { ep } = resolve(appName); + const res = await apiFetch( + `/api/v1/a2a/tasks/by-session/${encodeURIComponent(sessionId)}/control`, + { cache: "no-store" }, + ep, + ); + if (!res.ok) throw new Error(await httpErrorMessage(res, "turn control failed")); + return res.json(); +} + +export async function controlTurn( + appName: string, + sessionId: string, + action: TurnControlAction, + expectedGeneration: number, +): Promise { + const { ep } = resolve(appName); + const res = await apiFetch( + `/api/v1/a2a/tasks/by-session/${encodeURIComponent(sessionId)}/control/${action}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Idempotency-Key": crypto.randomUUID(), + }, + body: JSON.stringify({ expectedGeneration }), + }, + ep, + ); + if (res.status === 409) { + const body = await res.json().catch(() => null) as { detail?: TurnControlState } | null; + if (body?.detail?.taskId) throw new TurnControlConflictError(body.detail); + } + if (!res.ok) throw new Error(await httpErrorMessage(res, "turn control failed")); + return res.json(); +} + export async function getAgentInfo(appName: string): Promise { const { app, ep } = resolve(appName); return fetchAgentInfo(app, ep, false); @@ -1698,6 +1802,7 @@ export interface RunArgs { userId: string; sessionId: string; text: string; + modelId?: string; attachments?: Attachment[]; invocation?: FrontendInvocation; /** Complete set of local BFF tool IDs selected for this run. */ @@ -1784,6 +1889,7 @@ export async function* runSSE({ userId, sessionId, text, + modelId, attachments = [], invocation, platformTools, @@ -1850,6 +1956,7 @@ export async function* runSSE({ session_id: sessionId, new_message: { role: "user", parts }, streaming: true, + ...(modelId?.trim() ? { model_id: modelId.trim() } : {}), ...(platformTools !== undefined ? { platform_tools: [...platformTools] } : {}), @@ -4098,6 +4205,8 @@ export interface CloudRuntime { memoryMb?: number | null; createdAt?: string; currentVersion?: number | null; + /** Product family used by the new-chat picker. */ + agentCategory?: "general" | "mpa"; /** True when this runtime was deployed by the current user (veadk:author). */ isMine: boolean; /** Server-authorized deletion capability for this managed Runtime. */ @@ -4307,6 +4416,7 @@ export class RuntimeListError extends Error { * page continues pagination; the server derives ownership from identity. */ export async function getRuntimes( opts: { + agentCategory?: "general" | "mpa"; nextToken?: string; pageSize?: number; region?: string; @@ -4319,6 +4429,7 @@ export async function getRuntimes( page_size: String(opts.pageSize ?? 30), region: opts.region ?? "all", }); + if (opts.agentCategory) p.set("agentCategory", opts.agentCategory); if (opts.nextToken) p.set("next_token", opts.nextToken); const res = await apiFetch(`/web/runtimes?${p.toString()}`, { signal: opts.signal, @@ -4811,7 +4922,7 @@ export interface RuntimeDetail { maxInstance?: number | null; maxConcurrency?: number | null; }; - envs: { key: string; value: string }[]; + envs: { key: string; value: string; sensitive?: boolean; configured?: boolean }[]; memoryId: string; toolId: string; knowledgeId: string; @@ -4875,6 +4986,21 @@ export async function getRuntimeDetail( } } +export async function copyRuntimeEnvironmentSecret( + runtimeId: string, + region: string, + key: string, +): Promise { + const params = new URLSearchParams({ runtimeId, region, key }); + const response = await apiFetch(`/web/runtime-env/copy?${params.toString()}`, { method: "POST" }); + if (!response.ok) throw new Error(await httpErrorMessage(response, adkT("client.copySecretFailed"))); + const payload = await response.json() as { value?: unknown }; + if (typeof payload.value !== "string" || !payload.value) { + throw new Error(adkT("client.copySecretFailed")); + } + await navigator.clipboard.writeText(payload.value); +} + export function getCachedRuntimeDetail( runtimeId: string, region = "cn-beijing", @@ -5119,3 +5245,10 @@ export async function updateSandboxTool(kind: SandboxToolKind): Promise<{ if (typeof payload.updated !== "boolean") throw new Error(adkT("client.invalidSandboxUpdate")); return { updated: payload.updated, state: sandboxImageState(payload.state) }; } + +export function listMpaCronTasks(runtime: MpaRuntime, offset: number, query: string, signal?: AbortSignal) { + return fetchMpaCronTasks(apiFetch, runtime, offset, query, signal); +} + +export function requestMpaTask(runtime: MpaRuntime, method: string, suffix: string, payload?: unknown, signal?: AbortSignal) { return manageMpaTask(apiFetch, runtime, method, suffix, payload, signal); } +export function listMpaRuns(runtime: MpaRuntime, taskId: string, offset: number, signal?: AbortSignal) { return fetchMpaRuns(apiFetch, runtime, taskId, offset, signal); } diff --git a/frontend/src/adk/mpaCronTasks.ts b/frontend/src/adk/mpaCronTasks.ts new file mode 100644 index 000000000..00dfb523f --- /dev/null +++ b/frontend/src/adk/mpaCronTasks.ts @@ -0,0 +1,210 @@ +import type { AdkEndpoint } from "./client"; + +export interface MpaRuntime { + runtimeId: string; + region: string; + name: string; +} +export interface MpaCronTask { + id: string; + name: string; + enabled: boolean; + schedule: Record & { type: string }; + prompt?: string; + agentId?: string; + version?: number; + delivery?: Record; + jitterSeconds?: number; + timeoutSeconds?: number; + lastRunAt?: string | null; + runningAt?: string | null; + createdAt?: string; + nextRunAt?: string | null; + lastRunStatus?: string | null; +} +export interface MpaCronTaskPage { + overview?: { executionCount: number; successRate: number }; + items: MpaCronTask[]; + total: number; + hasMore: boolean; + nextOffset: number | null; +} +type Request = ( + path: string, + init: RequestInit, + endpoint: AdkEndpoint, +) => Promise; + +function validSchedule(schedule: Record): boolean { + if (schedule.timezone != null) { + if (typeof schedule.timezone !== "string") return false; + try { + new Intl.DateTimeFormat("en-US", { timeZone: schedule.timezone }); + } catch { + return false; + } + } + if ( + schedule.runAt != null && + (typeof schedule.runAt !== "string" || + !Number.isFinite(Date.parse(schedule.runAt))) + ) + return false; + return [schedule.weekdays, schedule.monthDays].every( + (days) => + days == null || (Array.isArray(days) && days.every(Number.isInteger)), + ); +} + +export async function fetchMpaCronTasks( + request: Request, + runtime: MpaRuntime, + offset: number, + query: string, + signal?: AbortSignal, +): Promise { + const response = await request( + `/web/mpa-cron/${encodeURIComponent(runtime.runtimeId)}?region=${encodeURIComponent(runtime.region)}&offset=${offset}&query=${encodeURIComponent(query)}`, + { signal }, + {}, + ); + // Do not expose arbitrary upstream bodies, which may contain credentials. + if (!response.ok) throw new Error(`MPA_HTTP_${response.status}`); + let data; + try { + data = await response.json(); + } catch { + throw new Error("MPA_INVALID_RESPONSE"); + } + if ( + !data || + !Array.isArray(data.items) || + !Number.isInteger(data.total) || + data.total < 0 || + typeof data.hasMore !== "boolean" || + (data.hasMore && + (!Number.isInteger(data.nextOffset) || data.nextOffset <= offset)) || + (data.overview != null && + (!Number.isFinite(data.overview.executionCount) || + !Number.isFinite(data.overview.successRate))) || + !data.items.every( + (item: MpaCronTask) => + item && + typeof item.id === "string" && + typeof item.name === "string" && + typeof item.enabled === "boolean" && + (item.prompt == null || typeof item.prompt === "string") && + item.schedule && + typeof item.schedule.type === "string" && + validSchedule(item.schedule) && + [item.agentId, item.lastRunAt, item.runningAt, item.createdAt].every( + (value) => value == null || typeof value === "string", + ) && + (item.nextRunAt == null || typeof item.nextRunAt === "string") && + (item.lastRunStatus == null || typeof item.lastRunStatus === "string"), + ) + ) { + throw new Error("MPA_INVALID_RESPONSE"); + } + return data; +} + +export interface MpaRun { + id: string; + status: string; + startedAt?: string | null; + scheduledAt: string; + durationMs?: number | null; + errorMessage?: string | null; + sessionId?: string | null; +} +export interface MpaRunPage { + items: MpaRun[]; + total: number; + hasMore: boolean; + nextOffset: number | null; +} +export type TaskFields = Pick & { + agentId?: string; + prompt: string; + delivery?: Record; + jitterSeconds?: number; + timeoutSeconds?: number; +}; +export async function manageMpaTask( + request: Request, + runtime: MpaRuntime, + method: string, + suffix: string, + payload?: unknown, + signal?: AbortSignal, +): Promise> { + const response = await request( + `/web/mpa-cron/${encodeURIComponent(runtime.runtimeId)}${suffix}?region=${encodeURIComponent(runtime.region)}`, + { + method, + signal, + ...(payload === undefined + ? {} + : { + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }), + }, + {}, + ); + if (!response.ok) throw new Error(`MPA_HTTP_${response.status}`); + let data; + try { + data = await response.json(); + } catch { + throw new Error("MPA_INVALID_RESPONSE"); + } + if (!data || typeof data !== "object" || Array.isArray(data)) + throw new Error("MPA_INVALID_RESPONSE"); + return data; +} + +export async function fetchMpaRuns( + request: Request, + runtime: MpaRuntime, + taskId: string, + offset: number, + signal?: AbortSignal, +): Promise { + const response = await request( + `/web/mpa-cron/${encodeURIComponent(runtime.runtimeId)}/${encodeURIComponent(taskId)}/runs?region=${encodeURIComponent(runtime.region)}&offset=${offset}`, + { signal }, + {}, + ); + if (!response.ok) throw new Error(`MPA_HTTP_${response.status}`); + let data; + try { + data = await response.json(); + } catch { + throw new Error("MPA_INVALID_RESPONSE"); + } + if ( + !data || + !Array.isArray(data.items) || + !Number.isInteger(data.total) || + data.total < 0 || + typeof data.hasMore !== "boolean" || + (data.hasMore && + (!Number.isInteger(data.nextOffset) || data.nextOffset <= offset)) || + !data.items.every( + (run: MpaRun) => + run && + typeof run.id === "string" && + typeof run.status === "string" && + typeof run.scheduledAt === "string" && + [run.startedAt, run.errorMessage, run.sessionId].every( + (value) => value == null || typeof value === "string", + ) && + (run.durationMs == null || + (Number.isFinite(run.durationMs) && run.durationMs >= 0)), + ) + ) + throw new Error("MPA_INVALID_RESPONSE"); + return data; +} diff --git a/frontend/src/blocks.ts b/frontend/src/blocks.ts index 0565cd7cc..930f82721 100644 --- a/frontend/src/blocks.ts +++ b/frontend/src/blocks.ts @@ -94,7 +94,13 @@ export interface CodexSandboxActivity { export type Block = | { kind: "progress"; text: string } - | { kind: "thinking"; text: string; done: boolean } + | { kind: "activity-source"; label: string } + | { + kind: "thinking"; + text: string; + done: boolean; + thoughtKind?: "reasoning" | "thought"; + } | { kind: "text"; text: string } | { kind: "tool"; @@ -105,6 +111,7 @@ export type Block = done: boolean; status?: "running" | "completed" | "failed"; defaultOpen?: boolean; + source?: "codex-sandbox" | "studio" | "runtime"; codexActivity?: CodexSandboxActivity; } | { @@ -148,6 +155,7 @@ export interface Acc { } export interface TurnMeta { + a2aStatus?: string; author?: string; localId?: string; streaming?: boolean; @@ -183,6 +191,7 @@ export function emptyAcc(): Acc { } const MAX_PENDING_CODEX_PROGRESS = 64; +const MAX_SEEN_EVENT_IDS = 2_048; function applyCodexProgressToTool( blocks: Block[], @@ -198,7 +207,10 @@ function applyCodexProgressToTool( if (block.kind !== "tool" || block.name !== progress.toolName) continue; if (block.callId === progress.requestId) { if (block.done) return "completed"; - block.codexActivity = applyCodexSandboxProgress(block.codexActivity, progress); + block.codexActivity = applyCodexSandboxProgress( + block.codexActivity, + progress, + ); block.status = progress.terminalStatus ?? "running"; if (progress.terminalStatus) block.done = true; return "applied"; @@ -214,7 +226,10 @@ function applyCodexProgressToTool( if (fallbackIndex >= 0) { const block = blocks[fallbackIndex]; if (block.kind !== "tool") return "unmatched"; - block.codexActivity = applyCodexSandboxProgress(block.codexActivity, progress); + block.codexActivity = applyCodexSandboxProgress( + block.codexActivity, + progress, + ); block.status = progress.terminalStatus ?? "running"; if (progress.terminalStatus) block.done = true; return "applied"; @@ -227,10 +242,13 @@ function codexResponseStatus(response: unknown): "completed" | "failed" { return "completed"; } const result = response as Record; - const status = typeof result.status === "string" ? result.status.toLowerCase() : ""; + const status = + typeof result.status === "string" ? result.status.toLowerCase() : ""; if ( - result.ok === false - || ["error", "failed", "denied", "declined", "cancelled", "timeout"].includes(status) + result.ok === false || + ["error", "failed", "denied", "declined", "cancelled", "timeout"].includes( + status, + ) ) { return "failed"; } @@ -238,7 +256,8 @@ function codexResponseStatus(response: unknown): "completed" | "failed" { } function codexDirectAnswer(response: unknown): string { - if (!response || typeof response !== "object" || Array.isArray(response)) return ""; + if (!response || typeof response !== "object" || Array.isArray(response)) + return ""; const result = response as Record; if (result.ok !== true || typeof result.message !== "string") return ""; return result.message.trim(); @@ -259,6 +278,60 @@ export interface AssistantEventProjection { const fnCall = (p: AdkPart) => p.functionCall ?? p.function_call; const fnResp = (p: AdkPart) => p.functionResponse ?? p.function_response; +function toolNamesMatch(left: string, right: string): boolean { + if (left === right) return true; + const commandAliases = new Set([ + "exec_command", + "commandExecution", + "command_execution", + "Run command", + ]); + return commandAliases.has(left) && commandAliases.has(right); +} + +function toolResponseState( + response: unknown, + partial = false, +): { done: boolean; status: "running" | "completed" | "failed" } { + const responseStatus = + response && typeof response === "object" + ? String((response as Record).status ?? "").toLowerCase() + : ""; + const failed = + ["failed", "error", "cancelled", "denied", "timeout"].includes( + responseStatus, + ) || (response as Record | undefined)?.ok === false; + const running = ["running", "started", "working"].includes( + responseStatus, + ); + return { + done: failed || !(partial || running), + status: failed ? "failed" : partial || running ? "running" : "completed", + }; +} + +export function flattenCodexActivityBlocks(blocks: Block[]): Block[] { + return blocks.flatMap((block) => { + if ( + block.kind !== "tool" || + block.name !== "delegate_to_codex_sandbox" || + !block.codexActivity?.items.length + ) + return [block]; + return [ + { + kind: "activity-source" as const, + label: block.codexActivity.title || "Codex Sandbox", + }, + ...block.codexActivity.items.map(({ block: child }) => + child.kind === "tool" + ? { ...child, source: "codex-sandbox" as const } + : child, + ), + ]; + }); +} + function transferAgentName(args: unknown): string { if (!args || typeof args !== "object") return ""; const record = args as Record; @@ -278,18 +351,20 @@ export function attachmentsFromParts(parts: AdkPart[]): AttachmentView[] { const files: AttachmentView[] = []; for (const [index, p] of parts.entries()) { const metadata = (p.partMetadata ?? p.part_metadata) as - | Record - | undefined; - const transport = metadata?.veadkTransport as Record | undefined; + Record | undefined; + const transport = metadata?.veadkTransport as + Record | undefined; if (transport?.hidden === true) continue; const stored = metadata?.veadkMedia as Record | undefined; if (typeof stored?.uri === "string") { files.push({ id: String(stored.id ?? stored.uri), - mimeType: typeof stored.mimeType === "string" ? stored.mimeType : undefined, + mimeType: + typeof stored.mimeType === "string" ? stored.mimeType : undefined, uri: stored.uri, name: typeof stored.name === "string" ? stored.name : undefined, - sizeBytes: typeof stored.sizeBytes === "number" ? stored.sizeBytes : undefined, + sizeBytes: + typeof stored.sizeBytes === "number" ? stored.sizeBytes : undefined, }); continue; } @@ -319,9 +394,9 @@ export function attachmentsFromParts(parts: AdkPart[]): AttachmentView[] { function visiblePartText(part: AdkPart): string | undefined { const metadata = (part.partMetadata ?? part.part_metadata) as - | Record - | undefined; - const transport = metadata?.veadkTransport as Record | undefined; + Record | undefined; + const transport = metadata?.veadkTransport as + Record | undefined; return transport?.hideText === true ? undefined : part.text; } @@ -334,7 +409,9 @@ const AGENT_NODE_TYPES = new Set([ ]); /** Restore slash-skill and @agent selections persisted in part metadata. */ -export function invocationFromParts(parts: AdkPart[]): FrontendInvocation | undefined { +export function invocationFromParts( + parts: AdkPart[], +): FrontendInvocation | undefined { for (const part of parts) { const raw = (part.partMetadata ?? part.part_metadata)?.veadkInvocation; if (!raw || typeof raw !== "object") continue; @@ -344,10 +421,15 @@ export function invocationFromParts(parts: AdkPart[]): FrontendInvocation | unde if (!item || typeof item !== "object") return []; const skill = item as Record; return typeof skill.name === "string" - ? [{ + ? [ + { name: skill.name, - description: typeof skill.description === "string" ? skill.description : "", - }] + description: + typeof skill.description === "string" + ? skill.description + : "", + }, + ] : []; }) : []; @@ -365,9 +447,12 @@ export function invocationFromParts(parts: AdkPart[]): FrontendInvocation | unde ) { targetAgent = { name: target.name, - description: typeof target.description === "string" ? target.description : "", + description: + typeof target.description === "string" ? target.description : "", type: type as AgentNodeType, - path: target.path.filter((item): item is string => typeof item === "string"), + path: target.path.filter( + (item): item is string => typeof item === "string", + ), }; } } @@ -383,24 +468,55 @@ function appendAttachments(blocks: Block[], files: AttachmentView[]) { else blocks.push({ kind: "attachment", files }); } -function appendArtifacts(blocks: Block[], files: { filename: string; version: number }[]) { +function appendArtifacts( + blocks: Block[], + files: { filename: string; version: number }[], +) { if (!files.length) return; const last = blocks[blocks.length - 1]; if (last?.kind === "artifact") { for (const file of files) { - if (!last.files.some((item) => - item.filename === file.filename && item.version === file.version - )) last.files.push(file); + if ( + !last.files.some( + (item) => + item.filename === file.filename && item.version === file.version, + ) + ) + last.files.push(file); } return; } blocks.push({ kind: "artifact", files }); } -function appendText(blocks: Block[], kind: "thinking" | "text", text: string) { +function appendText( + blocks: Block[], + kind: "thinking" | "text", + text: string, + thoughtKind?: "reasoning" | "thought", +) { const last = blocks[blocks.length - 1]; - if (last && last.kind === kind) last.text += text; - else blocks.push(kind === "thinking" ? { kind, text, done: false } : { kind, text }); + if ( + last && + last.kind === kind && + (last.kind !== "thinking" || last.thoughtKind === thoughtKind) + ) + last.text += text; + else + blocks.push( + kind === "thinking" + ? { kind, text, done: false, thoughtKind } + : { kind, text }, + ); +} + +function thoughtKindOf(event: AdkEvent): "reasoning" | "thought" { + const metadata = event.customMetadata ?? event.custom_metadata; + if (metadata && typeof metadata === "object") { + const value = (metadata as Record).thoughtKind; + if (value === "reasoning" || value === "thought") return value; + } + return "reasoning"; } function closeThinking(blocks: Block[]) { @@ -430,14 +546,20 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { for (let index = blocks.length - 1; index >= 0; index -= 1) { const block = blocks[index]; if ( - block.kind !== "tool" - || block.done - || block.name !== progress.toolName - || (progress.requestId && block.callId && block.callId !== progress.requestId) + block.kind !== "tool" || + block.done || + block.name !== progress.toolName || + (progress.requestId && + block.callId && + block.callId !== progress.requestId) ) { continue; } - block.response = applyBranchCompareProgress(block.args, block.response, progress); + block.response = applyBranchCompareProgress( + block.args, + block.response, + progress, + ); block.status = "running"; break; } @@ -445,8 +567,9 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { for (const progress of codexProgressUpdates) { const outcome = applyCodexProgressToTool(blocks, progress); if (outcome === "unmatched") { - pendingCodexProgress = [...pendingCodexProgress, progress] - .slice(-MAX_PENDING_CODEX_PROGRESS); + pendingCodexProgress = [...pendingCodexProgress, progress].slice( + -MAX_PENDING_CODEX_PROGRESS, + ); } } return { blocks, liveStart, pendingCodexProgress }; @@ -458,7 +581,12 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { for (const p of parts) { const text = visiblePartText(p); if (typeof text === "string" && text) - appendText(blocks, p.thought ? "thinking" : "text", text); + appendText( + blocks, + p.thought ? "thinking" : "text", + text, + p.thought ? thoughtKindOf(ev) : undefined, + ); } return { blocks, liveStart, pendingCodexProgress }; } @@ -472,7 +600,12 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { const files = attachmentsFromParts([p]); const text = visiblePartText(p); if (typeof text === "string" && text) { - appendText(blocks, p.thought ? "thinking" : "text", text); + appendText( + blocks, + p.thought ? "thinking" : "text", + text, + p.thought ? thoughtKindOf(ev) : undefined, + ); } else if (files.length) { closeThinking(blocks); appendAttachments(blocks, files); @@ -491,7 +624,9 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { const authConfig = args.authConfig ?? args.auth_config ?? args; // functionCallId looks like "_adk_toolset_auth_McpToolset"; surface the // toolset name so the card can say what is being authorized. - const rawId = String(args.functionCallId ?? args.function_call_id ?? ""); + const rawId = String( + args.functionCallId ?? args.function_call_id ?? "", + ); const label = rawId.replace(/^_adk_toolset_auth_/, "") || undefined; blocks.push({ kind: "auth", @@ -502,6 +637,23 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { done: false, }); } else { + const existingTool = fc.id + ? [...blocks] + .reverse() + .find( + (block: Block) => + block.kind === "tool" && block.callId === fc.id, + ) + : undefined; + if (existingTool?.kind === "tool") { + existingTool.name = fc.name ?? existingTool.name; + existingTool.args = fc.args ?? existingTool.args; + if (existingTool.response === undefined) { + existingTool.done = false; + existingTool.status = "running"; + } + continue; + } const toolBlock: Extract = { kind: "tool", name: fc.name ?? "", @@ -514,8 +666,8 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { const stillPending: CodexSandboxProgress[] = []; for (const progress of pendingCodexProgress) { if ( - progress.toolName === toolBlock.name - && progress.requestId === toolBlock.callId + progress.toolName === toolBlock.name && + progress.requestId === toolBlock.callId ) { applyCodexProgressToTool(blocks, progress); } else { @@ -546,34 +698,64 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { } } } + let matchedTool = false; for (let i = blocks.length - 1; i >= 0; i--) { const b = blocks[i]; - const isCodexTool = b.kind === "tool" && b.name === "delegate_to_codex_sandbox"; + const isCodexTool = + b.kind === "tool" && b.name === "delegate_to_codex_sandbox"; if ( - b.kind === "tool" - && (!b.done || isCodexTool) - && b.name === fr.name - && (!fr.id || !b.callId || b.callId === fr.id) + b.kind === "tool" && + (!b.done || isCodexTool || Boolean(fr.id && b.callId === fr.id)) && + ((Boolean(fr.id) && Boolean(b.callId) && b.callId === fr.id) || + ((!fr.id || !b.callId) && toolNamesMatch(b.name, fr.name ?? ""))) ) { const previousAnswer = isCodexTool ? codexDirectAnswer(b.response) : ""; - b.done = true; + const responseState = toolResponseState( + fr.response, + ev.partial === true, + ); + b.done = responseState.done; b.response = fr.response; + b.status = responseState.status; if (isCodexTool) { b.codexActivity = hydrateCodexSandboxActivity( b.codexActivity, fr.response, ); - b.status = codexResponseStatus(fr.response); + b.status = + responseState.status === "running" + ? "running" + : codexResponseStatus(fr.response); const answer = codexDirectAnswer(fr.response); if (answer && answer !== previousAnswer) { appendText(blocks, "text", answer); } } + matchedTool = true; break; } } + if ( + !matchedTool && + fr.name !== TRANSFER_AGENT_TOOL && + fr.name !== REQUEST_EUC && + fr.name !== A2UI_TOOL + ) { + const responseState = toolResponseState( + fr.response, + ev.partial === true, + ); + blocks.push({ + kind: "tool", + name: fr.name ?? "", + callId: fr.id, + response: fr.response, + done: responseState.done, + status: responseState.status, + }); + } if (fr.name === A2UI_TOOL) { const msgs = (fr.response?.[VALIDATED_JSON_KEY] as A2uiMessage[]) ?? []; if (msgs.length) { @@ -588,7 +770,10 @@ export function applyEvent(acc: Acc, ev: AdkEvent): Acc { if (artifactDelta) { appendArtifacts( blocks, - Object.entries(artifactDelta).map(([filename, version]) => ({ filename, version })), + Object.entries(artifactDelta).map(([filename, version]) => ({ + filename, + version, + })), ); } closeThinking(blocks); // a consolidated thinking segment is complete @@ -608,26 +793,42 @@ function completesAssistantResponse(ev: AdkEvent, blocks: Block[]): boolean { }); const hasA2ui = parts.some((part) => { const response = fnResp(part); - return response?.name === A2UI_TOOL && + return ( + response?.name === A2UI_TOOL && Array.isArray(response.response?.[VALIDATED_JSON_KEY]) && - response.response[VALIDATED_JSON_KEY].length > 0; + response.response[VALIDATED_JSON_KEY].length > 0 + ); }); const artifactDelta = ev.actions?.artifactDelta ?? ev.actions?.artifact_delta; - const hasArtifact = Boolean(artifactDelta && Object.keys(artifactDelta).length > 0); + const hasArtifact = Boolean( + artifactDelta && Object.keys(artifactDelta).length > 0, + ); const agentEnded = Boolean( - ev.actions?.endOfAgent ?? ev.actions?.end_of_agent ?? ev.actions?.escalate + ev.actions?.endOfAgent ?? ev.actions?.end_of_agent ?? ev.actions?.escalate, ); - const hasAnswerBlock = blocks.some((block) => + const hasAnswerBlock = blocks.some( + (block) => block.kind === "text" || block.kind === "attachment" || block.kind === "artifact" || block.kind === "a2ui" || - block.kind === "delivery" + block.kind === "delivery", + ); + return ( + hasFinalAnswerPart || + hasA2ui || + hasArtifact || + (agentEnded && hasAnswerBlock) ); - return hasFinalAnswerPart || hasA2ui || hasArtifact || (agentEnded && hasAnswerBlock); +} + +function a2aStatusOf(ev: AdkEvent): string | undefined { + const status = (ev.customMetadata ?? ev.custom_metadata)?.a2aStatus; + return typeof status === "string" ? status : undefined; } function eventAffectsAssistantTurn(ev: AdkEvent): boolean { + if (a2aStatusOf(ev)) return true; const artifactDelta = ev.actions?.artifactDelta ?? ev.actions?.artifact_delta; if (artifactDelta && Object.keys(artifactDelta).length > 0) return true; return (ev.content?.parts ?? []).some((part) => @@ -635,8 +836,8 @@ function eventAffectsAssistantTurn(ev: AdkEvent): boolean { visiblePartText(part) || attachmentsFromParts([part]).length > 0 || fnCall(part) || - fnResp(part) - ) + fnResp(part), + ), ); } @@ -651,6 +852,8 @@ export function createAssistantEventProjector( ) { let sequence = 0; const active = new Map(); + const seenEventIds = new Set(); + const eventIdOrder: string[] = []; let seededKey: string | undefined; const keyFor = (author: string, invocationId: string) => @@ -659,7 +862,8 @@ export function createAssistantEventProjector( if (initialTurn?.role === "assistant") { const author = initialTurn.meta?.author ?? ""; const invocationId = initialTurn.meta?.invocationId ?? ""; - const localId = initialTurn.meta?.localId ?? `${localIdPrefix}-${sequence++}`; + const localId = + initialTurn.meta?.localId ?? `${localIdPrefix}-${sequence++}`; const acc = emptyAcc(); acc.blocks = initialTurn.blocks; acc.liveStart = initialTurn.blocks.length; @@ -678,6 +882,22 @@ export function createAssistantEventProjector( return { project(ev: AdkEvent): AssistantEventProjection { + // Some Runtime streams replay the submitted user event. The UI already + // inserted that turn; agent-authored tool responses still belong here. + if (ev.author === "user" || (ev.id && seenEventIds.has(ev.id))) { + return { + turn: { role: "assistant", blocks: [] }, + completed: false, + ignored: true, + }; + } + if (ev.id) { + seenEventIds.add(ev.id); + eventIdOrder.push(ev.id); + if (eventIdOrder.length > MAX_SEEN_EVENT_IDS) { + seenEventIds.delete(eventIdOrder.shift()!); + } + } const author = ev.author && ev.author !== "user" ? ev.author : ""; const invocationId = ev.invocationId ?? ev.invocation_id ?? ""; const key = keyFor(author, invocationId); @@ -702,7 +922,10 @@ export function createAssistantEventProjector( state = { acc: emptyAcc(), localId, - meta: { author: author || undefined, invocationId: invocationId || undefined }, + meta: { + author: author || undefined, + invocationId: invocationId || undefined, + }, }; } @@ -714,6 +937,7 @@ export function createAssistantEventProjector( author: author || state.meta.author, localId: state.localId, streaming: !completed, + a2aStatus: a2aStatusOf(ev), tokens: usage?.totalTokenCount || state.meta.tokens, ts: ev.timestamp || state.meta.ts, invocationId: invocationId || state.meta.invocationId, @@ -736,7 +960,9 @@ export function createAssistantEventProjector( finish(): Turn[] { const turns = [...active.values()].map((state): Turn => ({ role: "assistant", - blocks: state.acc.blocks, + blocks: state.acc.blocks.map((block) => + block.kind === "thinking" ? { ...block, done: true } : block + ), meta: { ...state.meta, streaming: false }, })); active.clear(); @@ -745,7 +971,10 @@ export function createAssistantEventProjector( }; } -export function upsertProjectedAssistantTurn(turns: Turn[], projected: Turn): Turn[] { +export function upsertProjectedAssistantTurn( + turns: Turn[], + projected: Turn, +): Turn[] { const localId = projected.meta?.localId; if (!localId) return [...turns, projected]; const index = turns.findIndex((turn) => turn.meta?.localId === localId); @@ -776,7 +1005,10 @@ export function eventsToTurns( if (turns[i].role !== "assistant") continue; for (let j = turns[i].blocks.length - 1; j >= 0; j--) { const b = turns[i].blocks[j]; - if (b.kind === "auth") { b.done = true; break; } + if (b.kind === "auth") { + b.done = true; + break; + } } break; } @@ -799,7 +1031,8 @@ export function eventsToTurns( if (files.length) blocks.push({ kind: "attachment", files }); if (text) blocks.push({ kind: "text", text }); turns.push({ role: "user", blocks, meta: { ts: ev.timestamp } }); - projector = createAssistantEventProjector("adk-history"); + // Upserts span the full history, so each user turn needs a unique prefix. + projector = createAssistantEventProjector(`adk-history-${turns.length}`); } else { const projection = projector.project(ev); if (!projection.ignored) { diff --git a/frontend/src/cronjobs/CronJobs.tsx b/frontend/src/cronjobs/CronJobs.tsx index 9f3283f94..d8bb9e442 100644 --- a/frontend/src/cronjobs/CronJobs.tsx +++ b/frontend/src/cronjobs/CronJobs.tsx @@ -1,3 +1,5 @@ +import { MpaCronTasks } from "./MpaCronTasks"; +import type { MpaRuntime } from "../adk/mpaCronTasks"; import { useCallback, useEffect, @@ -77,6 +79,7 @@ import { CronJobFinalAnswer } from "./CronJobFinalAnswer"; import "./CronJobs.css"; interface CronJobsProps { + selectedRuntime?: MpaRuntime; cloudProvider: CloudProvider; } @@ -560,7 +563,8 @@ function JobDetail({ ); } -export function CronJobs({ cloudProvider }: CronJobsProps) { +export function CronJobs({ cloudProvider, selectedRuntime }: CronJobsProps) { + const [source, setSource] = useState("studio"); useTranslation("cronjobs"); const [jobs, setJobs] = useState([]); const [runtimes, setRuntimes] = useState([]); @@ -744,6 +748,11 @@ export function CronJobs({ cloudProvider }: CronJobsProps) { className="cronjobs-page-head" title={cronText("page.title")} /> + + + + {source === "runtime" ? : <> {loading && jobs.length === 0 ? : error ? {cronText("page.loadFailed")}{error} : 0} onCreate={() => setDrawerJob(null)} onSelect={(job) => setSelectedId(job.jobId)} />} + } {drawerJob !== undefined ? setDrawerJob(undefined)} onSubmit={submitDrawer} /> : null} ); diff --git a/frontend/src/cronjobs/MpaCronTasks.css b/frontend/src/cronjobs/MpaCronTasks.css new file mode 100644 index 000000000..77d73828c --- /dev/null +++ b/frontend/src/cronjobs/MpaCronTasks.css @@ -0,0 +1,427 @@ +.mpa-cron { + min-width: 0; + padding-bottom: 24px; + font-size: 13px; + color: hsl(var(--foreground)); +} +.mpa-cron-target { + display: flex; + gap: 8px 16px; + flex-wrap: wrap; + overflow-wrap: anywhere; + color: hsl(var(--muted-foreground)); + font-size: 12px; + margin-bottom: 20px; +} +.mpa-cron-target strong { + color: hsl(var(--foreground)); + font-weight: 500; +} +.mpa-cron-heading, +.mpa-toolbar, +.mpa-calendar-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.mpa-cron-heading { + margin-bottom: 24px; +} +.mpa-segment { + display: flex; + padding: 3px; + background: hsl(var(--muted)); + border-radius: 999px; +} +.mpa-segment button { + border: 0; + background: transparent; + color: hsl(var(--muted-foreground)); + padding: 4px 16px; + border-radius: 999px; + min-height: 28px; + cursor: pointer; +} +.mpa-segment button[aria-pressed="true"] { + background: hsl(var(--panel)); + color: hsl(var(--foreground)); +} +.mpa-create { + border-radius: 999px !important; + gap: 8px; +} +.mpa-cron-search { + display: flex; + gap: 8px; + align-items: center; +} +.mpa-cron-search input { + width: 180px; + min-width: 0; + height: 34px; + border: 1px solid hsl(var(--border)); + border-radius: 6px; + padding: 0 12px; + background: hsl(var(--panel)); + color: inherit; +} +.mpa-cron-overview { + display: flex; + gap: 16px; + flex-wrap: wrap; + color: hsl(var(--muted-foreground)); + font-size: 12px; + font-variant-numeric: tabular-nums; + margin-left: auto; +} +.mpa-cron-overview strong { + color: hsl(var(--foreground)); + font-weight: 500; +} +.mpa-cron-table { + overflow-x: auto; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + margin-top: 12px; +} +.mpa-cron table, +.mpa-detail table { + width: 100%; + border-collapse: collapse; + text-align: left; + table-layout: fixed; + min-width: 850px; +} +.mpa-cron th, +.mpa-cron td, +.mpa-detail th, +.mpa-detail td { + padding: 18px 20px; + border-bottom: 1px solid hsl(var(--border)); + vertical-align: middle; + font-weight: 400; + overflow-wrap: anywhere; +} +.mpa-cron th { + font-weight: 500; + padding-top: 12px; + padding-bottom: 12px; +} +.mpa-cron tbody tr:last-child td { + border-bottom: 0; +} +.mpa-cron tbody tr:hover { + background: hsl(var(--muted) / 0.45); +} +.mpa-cron th:last-child { + width: 140px; +} +.mpa-cron th:nth-child(3) { + width: 24%; +} +.mpa-cron th:nth-child(4) { + width: 180px; +} +.mpa-cron th .deployment-select { + width: 100%; +} +.mpa-task-link { + border: 0; + padding: 0; + background: none; + color: hsl(var(--feature-link)); + cursor: pointer; + text-align: left; + line-height: 1.6; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.mpa-status { + display: inline-block; + border: 1px solid hsl(var(--border)); + border-radius: 4px; + padding: 2px 8px; + white-space: nowrap; + color: hsl(var(--muted-foreground)); + font-size: 12px; +} +.mpa-status-Succeeded { + color: hsl(150 52% 35%); + border-color: hsl(150 35% 75%); +} +.mpa-status-Failed, +.mpa-status-TimedOut { + color: hsl(var(--destructive)); + border-color: hsl(var(--destructive) / 0.3); +} +.mpa-status-Running { + color: hsl(var(--primary)); +} +.mpa-actions { + display: flex; + align-items: center; + gap: 8px; +} +.mpa-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + background: none; + color: hsl(var(--muted-foreground)); + width: 32px; + height: 32px; + border-radius: 6px; + cursor: pointer; +} +.mpa-icon-button:hover { + background: hsl(var(--muted)); + color: hsl(var(--foreground)); +} +.mpa-switch { + display: inline-flex; + align-items: center; + border: 0; + padding: 3px; + border-radius: 999px; + background: hsl(var(--muted)); + width: 32px; + height: 20px; + cursor: pointer; + flex-shrink: 0; +} +.mpa-switch span { + width: 14px; + height: 14px; + border-radius: 50%; + background: hsl(var(--panel)); + border: 1px solid hsl(var(--border)); +} +.mpa-switch[aria-checked="true"] { + background: hsl(var(--primary)); + justify-content: flex-end; +} +.mpa-cron button:disabled { + opacity: 0.5; + cursor: not-allowed; +} +.mpa-cron :focus-visible { + outline: 2px solid hsl(var(--primary)); + outline-offset: 2px; +} +.mpa-cron-pages { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + font-variant-numeric: tabular-nums; +} +.mpa-cron-pages span { + margin-right: auto; + color: hsl(var(--muted-foreground)); +} +.mpa-state { + padding: 40px 24px; + text-align: center; + color: hsl(var(--muted-foreground)); +} +.mpa-cron-error { + color: hsl(var(--destructive)); + line-height: 1.6; +} +.mpa-dialog { + width: min(680px, calc(100vw - 32px)); +} +.mpa-editor fieldset { + border: 0; + padding: 0; + margin: 0; + display: grid; + gap: 16px; + min-width: 0; +} +.mpa-editor label { + display: flex; + flex-direction: column; + gap: 8px; + font-size: 13px; +} +.mpa-editor textarea { + height: auto; + resize: vertical; + min-height: 120px; +} +.mpa-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +.mpa-editor .mpa-checkbox { + flex-direction: row; + align-items: center; +} +.mpa-details { + display: grid; + grid-template-columns: 100px 1fr; + gap: 16px; + margin: 0; +} +.mpa-details dt { + color: hsl(var(--muted-foreground)); +} +.mpa-details dd { + margin: 0; + overflow-wrap: anywhere; +} +.mpa-prompt { + white-space: pre-wrap; + max-height: 220px; + overflow: auto; +} +.mpa-history-title { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 24px; +} +.mpa-history-title h3 { + font-size: 15px; + font-weight: 500; +} +.mpa-detail { + width: min(900px, calc(100vw - 32px)); +} +.mpa-detail table { + min-width: 650px; +} +.mpa-calendar-bar { + margin: 20px 0 12px; + gap: 8px; +} +.mpa-calendar-bar strong { + margin-right: auto; + font-size: 17px; + font-weight: 500; +} +.mpa-calendar-bar span { + font-size: 12px; + color: hsl(var(--muted-foreground)); +} +.mpa-calendar { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + border: 1px solid hsl(var(--border)); + border-radius: 8px; + overflow: hidden; +} +.mpa-calendar-weekday { + text-align: center; + padding: 12px; + color: hsl(var(--muted-foreground)); + background: hsl(var(--muted) / 0.35); +} +.mpa-calendar-day { + min-height: 110px; + padding: 8px; + border-top: 1px solid hsl(var(--border)); + border-right: 1px solid hsl(var(--border)); + overflow: hidden; +} +.mpa-calendar-day:nth-child(7n) { + border-right: 0; +} +.mpa-calendar-day time { + font-variant-numeric: tabular-nums; + display: block; + margin-bottom: 8px; +} +.mpa-calendar-day.is-outside { + background: hsl(var(--muted) / 0.3); + color: hsl(var(--muted-foreground)); +} +.mpa-calendar-task { + display: block; + width: 100%; + border: 0; + border-radius: 4px; + text-align: left; + padding: 4px 6px; + margin-top: 4px; + color: hsl(var(--primary)); + background: hsl(var(--primary) / 0.07); + cursor: pointer; + font-size: 12px; +} +.mpa-calendar-task span { + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.mpa-cron > p { + line-height: 1.6; +} +@media (max-width: 760px) { + .mpa-toolbar { + align-items: stretch; + } + .mpa-cron-overview { + margin-left: 0; + } + .mpa-cron-search { + width: 100%; + } + .mpa-cron-search input { + flex: 1; + } + .mpa-fields { + grid-template-columns: 1fr; + } + .mpa-calendar-day { + min-height: 80px; + padding: 4px; + } + .mpa-calendar-task { + padding: 4px 2px; + } + .mpa-details { + grid-template-columns: 1fr; + gap: 8px; + } +} +.mpa-status-filter { + display: inline-flex; + align-items: center; + gap: 8px; + background: none; + border: 0; + padding: 0; + color: inherit; + cursor: pointer; + white-space: nowrap; + min-height: 28px; +} +.mpa-status-filter .icon { + width: 14px; + height: 14px; + color: hsl(var(--muted-foreground)); +} +.mpa-editor input[type="checkbox"] { + appearance: auto; + width: 16px; + height: 16px; + accent-color: hsl(var(--primary)); +} +.mpa-dialog form { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.mpa-dialog .mpa-editor { + flex: 1; +} diff --git a/frontend/src/cronjobs/MpaCronTasks.tsx b/frontend/src/cronjobs/MpaCronTasks.tsx new file mode 100644 index 000000000..2eefbc13e --- /dev/null +++ b/frontend/src/cronjobs/MpaCronTasks.tsx @@ -0,0 +1,621 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Menu } from "@openai/apps-sdk-ui/components/Menu"; +import { listMpaCronTasks, requestMpaTask } from "../adk/client"; +import type { + MpaCronTask, + MpaCronTaskPage, + MpaRuntime, + TaskFields, +} from "../adk/mpaCronTasks"; +import { TextShimmer } from "../ui/text-shimmer/TextShimmer"; +import { DialogShell } from "../ui/SandboxControls"; +import { MpaTaskEditor } from "./MpaTaskEditor"; +import { MpaTaskDetail, mpaErrorKey } from "./MpaTaskDetail"; +import { MpaIcon } from "./MpaTaskIcons"; +import { + calendarTimes, + formatTime, + monthDays, + runStatuses, + scheduleText, + taskStatus, +} from "./mpaSchedule"; +import "../create/CustomCreate.css"; +import "../ui/ProjectPreview.css"; +import "./MpaCronTasks.css"; + +export function MpaCronTasks({ runtime }: { runtime?: MpaRuntime }) { + const { t } = useTranslation("cronjobs"); + return runtime ? ( + + ) : ( +

{t("mpa.selectRuntime")}

+ ); +} +function RuntimeTasks({ runtime }: { runtime: MpaRuntime }) { + const { t, i18n } = useTranslation("cronjobs"); + const label = (key: string) => t(`mpa.manage.${key}`); + const [draftQuery, setDraftQuery] = useState(""), + [query, setQuery] = useState(""), + [index, setIndex] = useState(0), + [refresh, setRefresh] = useState(0); + const [page, setPage] = useState(null), + [loading, setLoading] = useState(true), + [error, setError] = useState(""), + [notice, setNotice] = useState(""); + const [view, setView] = useState("list"), + [status, setStatus] = useState("all"), + [month, setMonth] = useState(() => new Date()); + const [editor, setEditor] = useState<{ + task?: MpaCronTask; + copy: boolean; + } | null>(null), + [detail, setDetail] = useState(null), + [deleting, setDeleting] = useState(null); + const [busy, setBusy] = useState(false), + [writeError, setWriteError] = useState(""); + const locked = useRef(false), + composing = useRef(false); + const lifetime = useRef(new AbortController()); + const tokens = useRef(new Map()); + useEffect(() => { + const controller = new AbortController(); + lifetime.current = controller; + return () => controller.abort(); + }, []); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + setPage(null); + void (async () => { + const first = await listMpaCronTasks( + runtime, + 0, + query, + controller.signal, + ); + const items = [...first.items]; + let current = first; + let offset = 0; + while (current.hasMore) { + if (controller.signal.aborted) return; + if ( + current.nextOffset === null || + current.nextOffset <= offset || + items.length > 100000 + ) + throw new Error("MPA_INVALID_RESPONSE"); + offset = current.nextOffset; + current = await listMpaCronTasks( + runtime, + offset, + query, + controller.signal, + ); + items.push(...current.items); + } + if (!controller.signal.aborted) + setPage({ + ...first, + items: [...new Map(items.map((task) => [task.id, task])).values()], + hasMore: false, + nextOffset: null, + }); + })() + .catch((e) => { + if (!controller.signal.aborted) setError(mpaErrorKey(e)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [runtime.runtimeId, runtime.region, query, refresh]); + const filtered = useMemo( + () => + page?.items.filter( + (task) => status === "all" || taskStatus(task) === status, + ) || [], + [page, status], + ); + const days = useMemo(() => monthDays(month), [month]); + const calendar = useMemo( + () => + days.map((day) => ({ + day, + tasks: filtered.flatMap((task) => { + const entry = calendarTimes(task, day); + return entry ? [{ task, ...entry }] : []; + }), + })), + [days, filtered], + ); + const visible = filtered.slice(index, index + 10); + function token(key: string) { + let value = tokens.current.get(key); + if (!value) { + value = crypto.randomUUID(); + tokens.current.set(key, value); + } + return value; + } + async function mutate( + kind: "create" | "edit" | "delete" | "run" | "toggle", + task?: MpaCronTask, + fields?: TaskFields, + ) { + if (locked.current) return; + locked.current = true; + setBusy(true); + setWriteError(""); + setNotice(""); + const operation = lifetime.current; + const key = + kind === "create" ? JSON.stringify(fields) : `${kind}:${task?.id}`; + try { + const suffix = task + ? `/${encodeURIComponent(task.id)}${kind === "run" ? "/run" : ""}` + : ""; + if ( + (kind === "edit" || kind === "toggle") && + (!Number.isInteger(task?.version) || Number(task?.version) < 1) + ) + throw new Error("MPA_INVALID_RESPONSE"); + const payload = + kind === "delete" + ? undefined + : kind === "run" + ? { clientToken: token(key), mode: "Force" } + : kind === "create" + ? { ...fields, clientToken: token(key) } + : { + ...(kind === "toggle" ? { enabled: !task!.enabled } : fields), + expectedVersion: task!.version, + }; + const result = await requestMpaTask( + runtime, + kind === "delete" ? "DELETE" : "POST", + suffix, + payload, + operation.signal, + ); + if ( + kind === "delete" + ? result.removed !== true + : kind === "run" + ? !result.run + : !result.task + ) + throw new Error("MPA_INVALID_RESPONSE"); + tokens.current.delete(key); + if (!operation.signal.aborted) { + setEditor(null); + setDeleting(null); + setDetail(null); + setNotice(label(kind === "run" ? "queued" : "saved")); + setIndex(0); + setRefresh((n) => n + 1); + } + } catch (e) { + if (!operation.signal.aborted) setWriteError(t(mpaErrorKey(e))); + } finally { + locked.current = false; + if (!operation.signal.aborted) setBusy(false); + } + } + function openEditor(task?: MpaCronTask, copy = false) { + setWriteError(""); + setEditor({ task, copy }); + } + return ( +
+
+ {runtime.name} + + {runtime.runtimeId} · {runtime.region} + +
+
+
+ {["list", "calendar"].map((mode) => ( + + ))} +
+ +
+
+
{ + e.preventDefault(); + if (composing.current) return; + setIndex(0); + setQuery(draftQuery); + setRefresh((n) => n + 1); + }} + > + setDraftQuery(e.target.value)} + onCompositionStart={() => { + composing.current = true; + }} + onCompositionEnd={() => { + composing.current = false; + }} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + (composing.current || + e.nativeEvent.isComposing || + e.keyCode === 229) + ) + e.preventDefault(); + }} + /> + + +
+ {page?.overview && ( +
+ {label("overview")} + + {t("mpa.successRate")}{" "} + {Math.round(page.overview.successRate * 100)}% + + + {t("mpa.executionCount")}{" "} + {page.overview.executionCount} + +
+ )} +
+ {error && ( +

+ {t(error)} +

+ )} + {writeError && !editor && !deleting && ( +

+ {writeError} +

+ )} + {notice &&

{notice}

} + {loading && ( +
+ {t("mpa.loading")} +
+ )} + {!loading && !error && page && ( + <> + {view === "list" ? ( + <> +
+ + + + + + + + + + + + + {visible.map((task) => ( + + + + + + + + + ))} + +
{label("name")}{label("lastRun")}{label("schedule")} + + + + + + {["all", "Pending", ...runStatuses].map((value) => ( + { + setStatus(value); + setIndex(0); + }} + > + {label(value)} + + ))} + + + {label("agent")}{label("actions")}
+ + {formatTime(task.lastRunAt, i18n.language)}{scheduleText(task, label, i18n.language)} + + {label(taskStatus(task))} + + {task.agentId || "—"} +
+ + + + + + + + {( + ["detail", "edit", "copy", "delete"] as const + ).map((action) => ( + { + if (action === "detail") setDetail(task); + else if (action === "delete") { + setWriteError(""); + setDeleting(task); + } else + openEditor(task, action === "copy"); + }} + > + + {label(action)} + + ))} + + +
+
+ {filtered.length === 0 && ( +
+ {t("mpa.empty")} +
+ )} +
+ + + ) : ( + <> +
+ + {new Intl.DateTimeFormat(i18n.language, { + year: "numeric", + month: "long", + }).format(month)} + + + {label("calendarZone")}{" "} + {Intl.DateTimeFormat().resolvedOptions().timeZone} + + + + +
+
+ {days.slice(0, 7).map((d) => ( +
+ {new Intl.DateTimeFormat(i18n.language, { + weekday: "short", + }).format(d)} +
+ ))} + {calendar.map(({ day, tasks }) => ( +
+ + {tasks.map(({ task, at, count, nextOnly }) => ( + + ))} +
+ ))} +
+

{label("calendarHint")}

+ + )} + + )} + {editor && ( + setEditor(null)} + onSave={(fields) => + void mutate( + editor.task && !editor.copy ? "edit" : "create", + editor.copy ? undefined : editor.task, + fields, + ) + } + /> + )} + {detail && ( + setDetail(null)} + /> + )} + {deleting && ( + } + busy={busy} + onClose={() => setDeleting(null)} + > +
+

{t("mpa.manage.deleteConfirm", { name: deleting.name })}

+ {writeError && ( +

+ {writeError} +

+ )} +
+
+ + +
+
+ )} +
+ ); +} diff --git a/frontend/src/cronjobs/MpaTaskDetail.tsx b/frontend/src/cronjobs/MpaTaskDetail.tsx new file mode 100644 index 000000000..af177bb03 --- /dev/null +++ b/frontend/src/cronjobs/MpaTaskDetail.tsx @@ -0,0 +1,147 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { listMpaRuns } from "../adk/client"; +import type { MpaCronTask, MpaRunPage, MpaRuntime } from "../adk/mpaCronTasks"; +import { DialogShell } from "../ui/SandboxControls"; +import { TextShimmer } from "../ui/text-shimmer/TextShimmer"; +import { formatTime, scheduleText } from "./mpaSchedule"; +import { MpaIcon } from "./MpaTaskIcons"; +export function mpaErrorKey(error: unknown): string { + const message = error instanceof Error ? error.message : ""; + return ( + ( + { + MPA_HTTP_401: "mpa.authRequired", + MPA_HTTP_403: "mpa.forbidden", + MPA_HTTP_404: "mpa.unsupported", + MPA_HTTP_409: "mpa.manage.conflict", + MPA_HTTP_422: "mpa.manage.invalid", + MPA_INVALID_RESPONSE: "mpa.invalidResponse", + } as Record + )[message] || "mpa.loadFailed" + ); +} +export function MpaTaskDetail({ + task, + runtime, + onClose, +}: { + task: MpaCronTask; + runtime: MpaRuntime; + onClose: () => void; +}) { + const { t, i18n } = useTranslation("cronjobs"); + const label = (key: string) => t(`mpa.manage.${key}`); + const [offset, setOffset] = useState(0), + [refresh, setRefresh] = useState(0), + [page, setPage] = useState(null), + [error, setError] = useState(""), + [loading, setLoading] = useState(true); + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(""); + setPage(null); + void listMpaRuns(runtime, task.id, offset, controller.signal) + .then((data) => { + if (!controller.signal.aborted) setPage(data); + }) + .catch((e) => { + if (!controller.signal.aborted) setError(mpaErrorKey(e)); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + return () => controller.abort(); + }, [runtime.runtimeId, runtime.region, task.id, offset, refresh]); + return ( + } + className="mpa-dialog mpa-detail" + onClose={onClose} + > +
+
+
{label("agent")}
+
{task.agentId || "—"}
+
{label("schedule")}
+
{scheduleText(task, label, i18n.language)}
+
{t("mpa.columns.next")}
+
{formatTime(task.nextRunAt, i18n.language)}
+
{label("prompt")}
+
{task.prompt}
+
+
+

{label("history")}

+ +
+ {error && ( +

+ {t(error)} +

+ )} + {loading && {t("mpa.loading")}} + {page && ( + <> +
+ + + + {["lastRun", "lastStatus", "duration", "result"].map( + (k) => ( + + ), + )} + + + + {page.items.map((run) => ( + + + + + + + ))} + +
{label(k)}
+ {formatTime( + run.startedAt || run.scheduledAt, + i18n.language, + )} + {label(run.status)} + {run.durationMs == null ? "—" : `${run.durationMs} ms`} + {run.errorMessage || run.sessionId || "—"}
+
+ {page.items.length === 0 &&

{label("noRuns")}

} + + + )} +
+
+ ); +} diff --git a/frontend/src/cronjobs/MpaTaskEditor.tsx b/frontend/src/cronjobs/MpaTaskEditor.tsx new file mode 100644 index 000000000..321b5be84 --- /dev/null +++ b/frontend/src/cronjobs/MpaTaskEditor.tsx @@ -0,0 +1,346 @@ +import { useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { DialogShell } from "../ui/SandboxControls"; +import { DeploymentSelect } from "../ui/DeploymentSelect"; +import type { MpaCronTask, TaskFields } from "../adk/mpaCronTasks"; +import { scheduleTypes } from "./mpaSchedule"; +import { MpaIcon } from "./MpaTaskIcons"; + +export function MpaTaskEditor({ + task, + copy, + busy, + error, + onClose, + onSave, +}: { + task?: MpaCronTask; + copy: boolean; + busy: boolean; + error: string; + onClose: () => void; + onSave: (fields: TaskFields) => void; +}) { + const { t } = useTranslation("cronjobs"); + const label = (key: string) => t(`mpa.manage.${key}`); + const initial = task?.schedule; + const [name, setName] = useState( + task ? task.name + (copy ? ` (${label("copy")})` : "") : "", + ); + const [prompt, setPrompt] = useState(task?.prompt || ""); + const [type, setType] = useState(initial?.type || "Daily"); + const [zone, setZone] = useState( + String(initial?.timezone || "Asia/Shanghai"), + ); + const [time, setTime] = useState(String(initial?.time || "09:00")); + const [date, setDate] = useState(() => { + if (!initial?.runAt) return ""; + const d = new Date(String(initial.runAt)); + return new Date(d.getTime() - d.getTimezoneOffset() * 60000) + .toISOString() + .slice(0, 16); + }); + const [interval, setInterval] = useState( + String(initial?.intervalSeconds || 3600), + ); + const [days, setDays] = useState(String(initial?.weekdays || "1,2,3,4,5")); + const [monthDays, setMonthDays] = useState(String(initial?.monthDays || "1")); + const [cron, setCron] = useState( + String(initial?.cronExpression || "0 9 * * *"), + ); + const [enabled, setEnabled] = useState(task?.enabled ?? true); + const [channel, setChannel] = useState( + String(task?.delivery?.channel || "Web"), + ); + const [target, setTarget] = useState(String(task?.delivery?.targetId || "")); + const [validation, setValidation] = useState(""); + const composing = useRef(false); + const nameRef = useRef(null); + function save() { + if (busy || composing.current) return; + try { + new Intl.DateTimeFormat("en-US", { timeZone: zone }); + } catch { + setValidation(label("timezoneInvalid")); + return; + } + const schedule: TaskFields["schedule"] = { type, timezone: zone }; + if (type === "Once") { + const at = Date.parse(date); + if (!Number.isFinite(at) || at <= Date.now()) { + setValidation(label("futureTime")); + return; + } + schedule.runAt = new Date(at).toISOString(); + } else if (type === "Interval") { + const seconds = Number(interval); + if (!Number.isInteger(seconds) || seconds < 30 || seconds > 31536000) { + setValidation(label("intervalInvalid")); + return; + } + schedule.intervalSeconds = seconds; + if (initial?.type === type && initial.anchorAt) + schedule.anchorAt = initial.anchorAt; + } else if (type === "Cron") { + if (cron.trim().split(/\s+/).length !== 5) { + setValidation(t("validation.cronFields")); + return; + } + schedule.cronExpression = cron.trim(); + } else { + schedule.time = time; + if (type === "Weekly" || type === "Monthly") { + const value = (type === "Weekly" ? days : monthDays) + .split(",") + .map(Number); + const max = type === "Weekly" ? 7 : 31; + if ( + !value.length || + value.some((n) => !Number.isInteger(n) || n < 1 || n > max) || + new Set(value).size !== value.length + ) { + setValidation(label("daysInvalid")); + return; + } + schedule[type === "Weekly" ? "weekdays" : "monthDays"] = value; + } + } + if ( + !name.trim() || + !prompt.trim() || + (channel === "Feishu" && !target.trim()) + ) { + setValidation(label("required")); + return; + } + const delivery = + task?.delivery?.channel === channel && + String(task.delivery.targetId || "") === target + ? task.delivery + : { + channel, + targetId: channel === "Web" ? null : target.trim(), + receiveIdType: "chat_id", + bestEffort: true, + }; + setValidation(""); + onSave({ + name: name.trim(), + ...(task?.agentId ? { agentId: task.agentId } : {}), + prompt: prompt.trim(), + enabled, + schedule, + delivery, + jitterSeconds: task?.jitterSeconds ?? 0, + timeoutSeconds: task?.timeoutSeconds ?? 3600, + }); + } + return ( + } + className="mpa-dialog" + initialFocusRef={nameRef} + busy={busy} + onClose={onClose} + > +
{ + e.preventDefault(); + save(); + }} + onCompositionStart={() => { + composing.current = true; + }} + onCompositionEnd={() => { + composing.current = false; + }} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + (composing.current || + e.nativeEvent.isComposing || + e.keyCode === 229) + ) + e.preventDefault(); + }} + > +
+
+ +