From ead17ea4683641013cb8b8062481ca7ee4c94562 Mon Sep 17 00:00:00 2001 From: vczh Date: Sun, 1 Mar 2026 19:07:14 -0800 Subject: [PATCH] Update prompt --- .github/Agent/.gitignore | 6 + .github/Agent/CopilotTools.md | 145 +++ .github/Agent/README.md | 179 +++ .github/Agent/package.json | 22 + .../CopilotPortal/assets/flowChartMermaid.js | 291 +++++ .../packages/CopilotPortal/assets/index.css | 214 ++++ .../packages/CopilotPortal/assets/index.html | 52 + .../packages/CopilotPortal/assets/index.js | 358 ++++++ .../CopilotPortal/assets/jobTracking.css | 174 +++ .../CopilotPortal/assets/jobTracking.html | 25 + .../CopilotPortal/assets/jobTracking.js | 573 +++++++++ .../packages/CopilotPortal/assets/jobs.css | 172 +++ .../packages/CopilotPortal/assets/jobs.html | 25 + .../packages/CopilotPortal/assets/jobs.js | 196 +++ .../CopilotPortal/assets/messageBlock.css | 116 ++ .../CopilotPortal/assets/messageBlock.js | 176 +++ .../CopilotPortal/assets/sessionResponse.css | 18 + .../CopilotPortal/assets/sessionResponse.js | 146 +++ .../packages/CopilotPortal/assets/test.html | 16 + .../Agent/packages/CopilotPortal/package.json | 22 + .../packages/CopilotPortal/src/copilotApi.ts | 254 ++++ .../CopilotPortal/src/copilotSession.ts | 191 +++ .../Agent/packages/CopilotPortal/src/index.ts | 319 +++++ .../packages/CopilotPortal/src/jobsApi.ts | 489 ++++++++ .../packages/CopilotPortal/src/jobsChart.ts | 167 +++ .../packages/CopilotPortal/src/jobsData.ts | 609 ++++++++++ .../packages/CopilotPortal/src/jobsDef.ts | 481 ++++++++ .../packages/CopilotPortal/src/sharedApi.ts | 313 +++++ .../packages/CopilotPortal/src/taskApi.ts | 904 ++++++++++++++ .../packages/CopilotPortal/test/api.test.mjs | 1042 ++++++++++++++++ .../CopilotPortal/test/jobsData.test.mjs | 1058 +++++++++++++++++ .../CopilotPortal/test/liveOptimize.test.mjs | 311 +++++ .../packages/CopilotPortal/test/runTests.mjs | 41 + .../CopilotPortal/test/startServer.mjs | 38 + .../CopilotPortal/test/testEntry.json | 222 ++++ .../packages/CopilotPortal/test/web.index.mjs | 340 ++++++ .../packages/CopilotPortal/test/web.jobs.mjs | 616 ++++++++++ .../packages/CopilotPortal/test/web.test.mjs | 27 + .../CopilotPortal/test/windowsHide.cjs | 16 + .../packages/CopilotPortal/test/work.test.mjs | 425 +++++++ .../packages/CopilotPortal/tsconfig.json | 9 + .../prompts/snapshot/CopilotPortal/API.md | 152 +++ .../prompts/snapshot/CopilotPortal/API_Job.md | 234 ++++ .../snapshot/CopilotPortal/API_Session.md | 331 ++++++ .../snapshot/CopilotPortal/API_Task.md | 239 ++++ .../prompts/snapshot/CopilotPortal/Index.md | 124 ++ .../prompts/snapshot/CopilotPortal/Jobs.md | 254 ++++ .../snapshot/CopilotPortal/JobsData.md | 330 +++++ .../prompts/snapshot/CopilotPortal/Shared.md | 122 ++ .../prompts/snapshot/CopilotPortal/Test.md | 13 + .github/Agent/prompts/spec.prompt.md | 116 ++ .../Agent/prompts/spec/CopilotPortal/API.md | 152 +++ .../prompts/spec/CopilotPortal/API_Job.md | 234 ++++ .../prompts/spec/CopilotPortal/API_Session.md | 331 ++++++ .../prompts/spec/CopilotPortal/API_Task.md | 239 ++++ .../Agent/prompts/spec/CopilotPortal/Index.md | 124 ++ .../Agent/prompts/spec/CopilotPortal/Jobs.md | 254 ++++ .../prompts/spec/CopilotPortal/JobsData.md | 330 +++++ .../prompts/spec/CopilotPortal/Shared.md | 122 ++ .../Agent/prompts/spec/CopilotPortal/Test.md | 13 + .github/Agent/prompts/verifyJobs.prompt.md | 32 + .github/Agent/prompts/verifySpec.prompt.md | 40 + .github/Agent/tsconfig.json | 20 + .github/Agent/yarn.lock | 107 ++ .github/Scripts/copilotGitCommit.ps1 | 5 + .github/bot.ps1 | 9 + .gitignore | 1 + 67 files changed, 14726 insertions(+) create mode 100644 .github/Agent/.gitignore create mode 100644 .github/Agent/CopilotTools.md create mode 100644 .github/Agent/README.md create mode 100644 .github/Agent/package.json create mode 100644 .github/Agent/packages/CopilotPortal/assets/flowChartMermaid.js create mode 100644 .github/Agent/packages/CopilotPortal/assets/index.css create mode 100644 .github/Agent/packages/CopilotPortal/assets/index.html create mode 100644 .github/Agent/packages/CopilotPortal/assets/index.js create mode 100644 .github/Agent/packages/CopilotPortal/assets/jobTracking.css create mode 100644 .github/Agent/packages/CopilotPortal/assets/jobTracking.html create mode 100644 .github/Agent/packages/CopilotPortal/assets/jobTracking.js create mode 100644 .github/Agent/packages/CopilotPortal/assets/jobs.css create mode 100644 .github/Agent/packages/CopilotPortal/assets/jobs.html create mode 100644 .github/Agent/packages/CopilotPortal/assets/jobs.js create mode 100644 .github/Agent/packages/CopilotPortal/assets/messageBlock.css create mode 100644 .github/Agent/packages/CopilotPortal/assets/messageBlock.js create mode 100644 .github/Agent/packages/CopilotPortal/assets/sessionResponse.css create mode 100644 .github/Agent/packages/CopilotPortal/assets/sessionResponse.js create mode 100644 .github/Agent/packages/CopilotPortal/assets/test.html create mode 100644 .github/Agent/packages/CopilotPortal/package.json create mode 100644 .github/Agent/packages/CopilotPortal/src/copilotApi.ts create mode 100644 .github/Agent/packages/CopilotPortal/src/copilotSession.ts create mode 100644 .github/Agent/packages/CopilotPortal/src/index.ts create mode 100644 .github/Agent/packages/CopilotPortal/src/jobsApi.ts create mode 100644 .github/Agent/packages/CopilotPortal/src/jobsChart.ts create mode 100644 .github/Agent/packages/CopilotPortal/src/jobsData.ts create mode 100644 .github/Agent/packages/CopilotPortal/src/jobsDef.ts create mode 100644 .github/Agent/packages/CopilotPortal/src/sharedApi.ts create mode 100644 .github/Agent/packages/CopilotPortal/src/taskApi.ts create mode 100644 .github/Agent/packages/CopilotPortal/test/api.test.mjs create mode 100644 .github/Agent/packages/CopilotPortal/test/jobsData.test.mjs create mode 100644 .github/Agent/packages/CopilotPortal/test/liveOptimize.test.mjs create mode 100644 .github/Agent/packages/CopilotPortal/test/runTests.mjs create mode 100644 .github/Agent/packages/CopilotPortal/test/startServer.mjs create mode 100644 .github/Agent/packages/CopilotPortal/test/testEntry.json create mode 100644 .github/Agent/packages/CopilotPortal/test/web.index.mjs create mode 100644 .github/Agent/packages/CopilotPortal/test/web.jobs.mjs create mode 100644 .github/Agent/packages/CopilotPortal/test/web.test.mjs create mode 100644 .github/Agent/packages/CopilotPortal/test/windowsHide.cjs create mode 100644 .github/Agent/packages/CopilotPortal/test/work.test.mjs create mode 100644 .github/Agent/packages/CopilotPortal/tsconfig.json create mode 100644 .github/Agent/prompts/snapshot/CopilotPortal/API.md create mode 100644 .github/Agent/prompts/snapshot/CopilotPortal/API_Job.md create mode 100644 .github/Agent/prompts/snapshot/CopilotPortal/API_Session.md create mode 100644 .github/Agent/prompts/snapshot/CopilotPortal/API_Task.md create mode 100644 .github/Agent/prompts/snapshot/CopilotPortal/Index.md create mode 100644 .github/Agent/prompts/snapshot/CopilotPortal/Jobs.md create mode 100644 .github/Agent/prompts/snapshot/CopilotPortal/JobsData.md create mode 100644 .github/Agent/prompts/snapshot/CopilotPortal/Shared.md create mode 100644 .github/Agent/prompts/snapshot/CopilotPortal/Test.md create mode 100644 .github/Agent/prompts/spec.prompt.md create mode 100644 .github/Agent/prompts/spec/CopilotPortal/API.md create mode 100644 .github/Agent/prompts/spec/CopilotPortal/API_Job.md create mode 100644 .github/Agent/prompts/spec/CopilotPortal/API_Session.md create mode 100644 .github/Agent/prompts/spec/CopilotPortal/API_Task.md create mode 100644 .github/Agent/prompts/spec/CopilotPortal/Index.md create mode 100644 .github/Agent/prompts/spec/CopilotPortal/Jobs.md create mode 100644 .github/Agent/prompts/spec/CopilotPortal/JobsData.md create mode 100644 .github/Agent/prompts/spec/CopilotPortal/Shared.md create mode 100644 .github/Agent/prompts/spec/CopilotPortal/Test.md create mode 100644 .github/Agent/prompts/verifyJobs.prompt.md create mode 100644 .github/Agent/prompts/verifySpec.prompt.md create mode 100644 .github/Agent/tsconfig.json create mode 100644 .github/Agent/yarn.lock create mode 100644 .github/Scripts/copilotGitCommit.ps1 create mode 100644 .github/bot.ps1 diff --git a/.github/Agent/.gitignore b/.github/Agent/.gitignore new file mode 100644 index 00000000..1a001eec --- /dev/null +++ b/.github/Agent/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.DS_Store +.env +*.tsbuildinfo diff --git a/.github/Agent/CopilotTools.md b/.github/Agent/CopilotTools.md new file mode 100644 index 00000000..d82f5ddc --- /dev/null +++ b/.github/Agent/CopilotTools.md @@ -0,0 +1,145 @@ +# Copilot CLI Predefined Tools + +This file lists the tools available in this Copilot CLI environment, with each tool’s offered description. + +## functions.powershell +Runs a PowerShell command in an interactive PowerShell session. +- The "command" parameter does NOT need to be XML-escaped. +- You don't have internet access via this tool. +- You can run Python, Node.js and Go code with `python`, `node` and `go`. +- Each shellId identifies a persistent session. State is saved across calls. +- `initial_wait` must be 30-600 seconds. Give long-running commands time to produce output. +- If a command hasn't completed within initial_wait, it returns partial output and continues running. Use `read_powershell` for more output or `stop_powershell` to stop it. +- You can install Python, JavaScript and Go packages with the `pip`, `npm` and `go` commands. +- Use native PowerShell commands not DOS commands (e.g., use Get-ChildItem rather than dir). DOS commands may not work. + +## functions.write_powershell +Sends input to the specified command or PowerShell session. +- This tool can be used to send input to a running PowerShell command or an interactive console app. +- PowerShell commands are run in an interactive PowerShell session with a TTY device and PowerShell command processor. +- shellId (required) must match the shellId used to invoke the async powershell command. +- You can send text, {up}, {down}, {left}, {right}, {enter}, and {backspace} as input. +- Some applications present a list of options to select from. The selection is often denoted using ❯, >, or different formatting. +- When presented with a list of items, make a selection by sending arrow keys like {up} or {down} to move the selection to your chosen item and then {enter} to select it. +- The response will contain any output read after "delay" seconds. Delay should be appropriate for the task and never less than 10 seconds. + +## functions.read_powershell +Reads output from a PowerShell command. +- Reads the output of a command running in an "async" PowerShell session. +- The shellId MUST be the same one used to invoke the PowerShell command. +- You can call this tool multiple times to read output produced since the last call. +- Each request has a cost, so provide a reasonable "delay" parameter value for the task, to minimize the need for repeated reads. +- If a read request generates no output, consider using exponential backoff in choosing the delay between reads of the same command. +- Though `write_powershell` accepts ANSI control codes, this tool does not include them in the output. + +## functions.stop_powershell +Stops a running PowerShell command. +- Stops a running PowerShell command by terminating the entire PowerShell session and process. +- This tool can be used to stop commands that have not exited on their own. +- Any environment variables defined will have to be redefined after using this tool if the same session ID is used to run a new command. +- The shellId must match the shellId used to invoke the powershell command. + +## functions.list_powershell +Lists all active PowerShell sessions. +- Returns information about all currently running PowerShell sessions. +- Useful for discovering shellIds to use with read_powershell, write_powershell, or stop_powershell. +- Shows shellId, command, mode, PID, status, and whether there is unread output. + +## functions.view +Tool for viewing files and directories. +- If `path` is an image file, returns the image as base64-encoded data along with its MIME type. +- If `path` is any other type of file, `view` displays the content with line numbers prefixed to each line in the format `N. ` where N is the line number (e.g., `1. `, `2. `, etc.). +- If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep +- Path MUST be absolute + +## functions.create +Tool for creating new files. +- Creates a new file with the specified content at the given path +- Cannot be used if the specified path already exists +- Parent directories must exist before creating the file +- Path MUST be absolute + +## functions.edit +Tool for making string replacements in files. +- Replaces exactly one occurrence of `old_str` with `new_str` in the specified file +- When called multiple times in a single response, edits are independently made in the order calls are specified +- The `old_str` parameter must match EXACTLY one or more consecutive lines from the original file +- If `old_str` is not unique in the file, replacement will not be performed +- Make sure to include enough context in `old_str` to make it unique +- Path MUST be absolute + +## functions.web_fetch +Fetches a URL from the internet and returns the page as either markdown or raw HTML. Use this to safely retrieve up-to-date information from HTML web pages. + +## functions.report_intent +Use this tool to update the current intent of the session. This is displayed in the user interface and is important to help the user understand what you're doing. +Rules: +- Call this tool ONLY when you are also calling other tools. Do not call this tool in isolation. +- Put this tool call first in your collection of tool calls. +- Always call it at least once per user message (on your first tool-calling turn after a user message). +- Don't then re-call it if the reported intent is still applicable +When to update intent (examples): +- ✅ "Exploring codebase" → "Installing dependencies" (new phase) +- ✅ "Running tests" → "Debugging test failures" (new phase) +- ✅ "Creating hook script" → "Fixing security issue" (new phase) +- ✅ "Creating parser tests" → "Fixing security issue" (new phase) +- ❌ "Installing Pandas 2.2.3" → "Installing Pandas with pip3" (same goal, different tactic: should just have said "Installing Pandas") +- ❌ "Running transformation script" → "Running with python3" (same goal, fallback attempt) +Phrasing guidelines: +- The intent text must be succinct - 4 words max +- Keep it high-level - it should summarize a series of steps and focus on the goal +- Use gerund form +- Bad examples: +- 'I am going to read the codebase and understand it.' (too long and no gerund) +- 'Writing test1.js' (too low-level: describe the goal, not the specific file) +- 'Updating logic' (too vague: at least add one word to hint at what logic) +- Good examples: +- 'Exploring codebase' +- 'Creating parser tests' +- 'Fixing homepage CSS' + +## functions.fetch_copilot_cli_documentation +Fetches documentation about you, the GitHub Copilot CLI, and your capabilities. Use this tool when the user asks how to use you, what you can do, or about specific features of the GitHub Copilot CLI. + +## functions.update_todo +Use this TODO tool to manage the tasks that must be completed to solve the problem. Use this tool VERY frequently to keep track of your progress towards completing the overall goal. +- Call this tool to make the initial todo list for a complex problem. Then call this tool every time you finish a task and check off the corresponding item in the TODO list. If new tasks are identified, add them to the list as well. Re-planning is allowed if necessary. +- This tool accepts markdown input to track what has been completed, and what still needs to be done. +- This tool does not return meaningful data or make changes to the repository, but helps you organize your work and keeps you on-task. +- Call this tool at the same time as the next necessary tool calls, so that you can keep your TODO list updated while continuing to make progress on the problem. + +## functions.grep +Fast and precise code search using ripgrep. Search for patterns in file contents. + +## functions.glob +Fast file pattern matching using glob patterns. Find files by name patterns. + +## functions.task +Custom agent: Launch specialized agents in separate context windows for specific tasks. + +The Task tool launches specialized agents that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it. + +Available agent types: +- explore: Fast agent specialized for exploring codebases and answering questions about code. Use this when you need to quickly find files by patterns, search code for keywords, or answer questions about the codebase. Returns focused answers under 300 words. Safe to call in parallel. (Tools: grep/glob/view, Haiku model) +- task: Agent for executing commands with verbose output (tests, builds, lints, dependency installs). Returns brief summary on success, full output on failure. Keeps main context clean by minimizing successful output. Use for tasks where you only need to know success/failure status. (Tools: All CLI tools, Haiku model) +- general-purpose: Full-capability agent running in a subprocess. Use for complex multi-step tasks requiring the complete toolset and high-quality reasoning. Runs in a separate context window to keep your main conversation clean. (Tools: All CLI tools, Sonnet model) +- code-review: Agent for reviewing code changes with extremely high signal-to-noise ratio. Analyzes staged/unstaged changes and branch diffs. Only surfaces issues that genuinely matter - bugs, security vulnerabilities, logic errors. Never comments on style, formatting, or trivial matters. Will NOT modify code. (Tools: All CLI tools for investigation) + +When NOT to use Task tool: +- Reading specific file paths you already know - use view tool instead +- Simple single grep/glob search - use grep/glob tools directly +- Commands where you need immediate full output in your context - use bash directly +- File operations on known files - use edit/create tools directly + +Usage notes: +- Can launch multiple explore/code-review agents in parallel (task, general-purpose have side effects) +- Each agent is stateless - provide complete context in your prompt +- Agent results are returned in a single message +- Use explore proactively for codebase questions before making changes + +## multi_tool_use.parallel +This tool serves as a wrapper for utilizing multiple tools. + +Use this function to run multiple tools simultaneously, but only if they can operate in parallel. +Only tools in the functions namespace are permitted. +Ensure that the parameters provided to each tool are valid according to that tool's specification. diff --git a/.github/Agent/README.md b/.github/Agent/README.md new file mode 100644 index 00000000..7e9bea7f --- /dev/null +++ b/.github/Agent/README.md @@ -0,0 +1,179 @@ +# Copilot Agent + +A TypeScript-based application for interacting with GitHub Copilot models, featuring a web-based portal. + +## Prerequisites + +- [GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli) must be installed and authenticated +- Node.js 18+ and Yarn + +## Setup + +From the `.github/Agent` directory, run: + +```bash +yarn install +``` + +## Build + +To build and test the application: + +```bash +yarn build +``` + +This runs three steps in sequence: +1. `yarn compile` — compiles all TypeScript packages +2. `yarn testStart` — starts the portal server in the background +3. `yarn testExecute` — runs all API and Playwright tests, then stops the server + +The server is always stopped via `api/stop` after tests, regardless of pass/fail. + +To compile only (no tests): + +```bash +yarn compile +``` + +## Usage + +### Portal (Web UI) + +```bash +yarn portal +``` + +Starts an HTTP server at `http://localhost:8888` serving a web UI and RESTful API. + +- Open `http://localhost:8888` to launch the portal. +- Select a model and working directory, then click Start. +- Send requests via the text box (Ctrl+Enter or Send button). +- Session responses (reasoning, tool calls, messages) appear as collapsible message blocks. +- "Close Session" ends the session and closes the tab; "Stop Server" also shuts down the server. +- Use `http://localhost:8888/index.html?project=XXX` to default working directory to the sibling folder `XXX` next to the repo root. +- Without `project` parameter, working directory defaults to the repo root. + +#### CLI Options + +- `--port ` — Set the HTTP server port (default: `8888`) +- `--test` — Start in test mode (entry is not pre-installed; enables `copilot/test/installJobsEntry` API for test-driven entry loading) + +### Portal for Test + +```bash +yarn portal-for-test +``` + +Starts the portal in test mode (`--test` flag). In test mode, the jobs entry is not installed at startup, and the `copilot/test/installJobsEntry` API is available for tests to install entries dynamically. + +## Specification Structure + +There are two folders storing specification: +- `prompts/snapshot`: The specification that the project implemented, it reflects the current state. +- `prompts/spec`: The specification that the project need to be. + +File organization in these two folders are identical: +- `CopilotPortal` folder: about `packages/CopilotPortal` + - `JobsData.md`: Definitions of jobs data. + - `API.md`: RESTful API, and how to start the project. More specific content in these files: + - `API_Session.md` + - `API_Task.md` + - `API_Job.md` + - `Index.md`: index.html page. + - `Jobs.md`: jobs.html and jobTracking.html pages. + - `Test.md`: test.html page. + - `Shared.md`: Shared components between multiple web pages. + +## Project Structure + +``` +.github/Agent/ +├── package.json # Workspace configuration (yarn workspaces) +├── tsconfig.json # Base TypeScript configuration +├── packages/ +│ ├── CopilotPortal/ # Web UI + RESTful API server +│ │ ├── src/ +│ │ │ ├── copilotSession.ts # Copilot SDK session wrapper +│ │ │ ├── copilotApi.ts # Copilot session API routes, token endpoint, and helpers +│ │ │ ├── taskApi.ts # Task execution engine and task API routes +│ │ │ ├── jobsApi.ts # Job API routes and job execution +│ │ │ ├── jobsDef.ts # Jobs/tasks data definitions and validation +│ │ │ ├── jobsChart.ts # Flow chart graph generation from work trees +│ │ │ ├── jobsData.ts # Preloaded jobs/tasks data +│ │ │ ├── sharedApi.ts # Shared HTTP utilities and token-based live entity state management +│ │ │ └── index.ts # HTTP server, API routing, static files, entry management +│ │ ├── assets/ # Static website files +│ │ │ ├── index.html # Main portal page +│ │ │ ├── index.js # Portal JS (session interaction, live polling) +│ │ │ ├── index.css # Portal styles +│ │ │ ├── jobs.html # Jobs selection page +│ │ │ ├── jobs.js # Jobs page JS (matrix rendering, job selection) +│ │ │ ├── jobs.css # Jobs page styles +│ │ │ ├── jobTracking.html # Job tracking page +│ │ │ ├── jobTracking.js # Job tracking JS (logic + renderer dispatch) +│ │ │ ├── jobTracking.css # Job tracking styles +│ │ │ ├── flowChartMermaid.js # Mermaid flow chart renderer +│ │ │ ├── messageBlock.js # MessageBlock component +│ │ │ ├── messageBlock.css # MessageBlock styles +│ │ │ ├── sessionResponse.js # SessionResponseRenderer component +│ │ │ ├── sessionResponse.css # SessionResponseRenderer styles +│ │ │ └── test.html # Simple API test page +│ │ ├── test/ # Test files +│ │ │ ├── startServer.mjs # Starts server in test mode for testing +│ │ │ ├── runTests.mjs # Test runner (always stops server) +│ │ │ ├── testEntry.json # Test entry with simple tasks/jobs for API tests +│ │ │ ├── jobsData.test.mjs # Jobs data validation tests +│ │ │ ├── api.test.mjs # RESTful API tests (incl. task/job execution) +│ │ │ ├── work.test.mjs # Work tree execution tests +│ │ │ ├── web.test.mjs # Playwright UI tests (test.html) +│ │ │ ├── web.index.mjs # Playwright tests for index.html +│ │ │ ├── web.jobs.mjs # Playwright tests for jobs.html and jobTracking.html +│ │ │ └── windowsHide.cjs # Windows process hiding helper +│ │ └── package.json +``` + +## Maintaining the Project + +- **Build**: `yarn build` compiles all packages and runs tests. +- **Compile only**: `yarn compile` compiles all packages via TypeScript. +- **Run portal**: `yarn portal` starts the web server (default port 8888). +- **Run portal in test mode**: `yarn portal-for-test` starts in test mode. +- **Run tests only**: `yarn testStart && yarn testExecute` starts server in test mode and runs tests. +- **Playwright**: Install with `npx playwright install chromium`. Used for testing the portal UI. +- **Spec-driven**: Portal features are defined in `prompts/spec/CopilotPortal/`. + +## Features + +- **Web Portal**: Browser-based UI for Copilot sessions with real-time streaming +- **Message Blocks**: User, Reasoning, Tool, and Message blocks with expand/collapse behavior +- **Markdown Rendering**: Completed message blocks (except Tool) render markdown content as formatted HTML using marked.js +- **Awaiting Status**: "Awaits responses ..." indicator shown in the session part while the agent is working +- **Lazy CopilotClient**: Client starts on demand and closes when the server shuts down +- **Multiple Sessions**: Supports parallel sessions sharing a single CopilotClient +- **Live Polling**: Token-based sequential long-polling for real-time session/task/job callbacks. Clients acquire a token via `api/token`, then poll `live/{token}` endpoints. Responses are stored in a list with per-token reading positions, enabling multiple consumers to independently read the same response history +- **Task System**: Job/task execution engine with availability checks, criteria validation, and retry logic +- **Session Crash Retry**: `sendMonitoredPrompt` (private method on `CopilotTaskImpl`) automatically retries if a Copilot session crashes during prompt execution, creating new sessions when needed. Driving sessions use `entry.drivingSessionRetries` budget with multi-model fallback; task sessions retry up to 5 times with the same model. +- **Detailed Error Reporting**: `errorToDetailedString` helper converts errors to detailed JSON with name, message, stack, and recursive cause chain for comprehensive crash diagnostics +- **Jobs API**: RESTful API for listing, starting, stopping, and monitoring tasks and jobs via live polling +- **Running Jobs API**: `copilot/job/running` lists all running or recently finished (within an hour) jobs with name, status, and start time; `copilot/job/{job-id}/status` returns detailed job status including per-task statuses +- **Running Jobs List**: The portal home page displays a list of running/recent jobs with name, status, and time, auto-loaded on page load. A "Refresh" button updates the list, and each item has a "View" button to inspect the job in the tracking page +- **Initial Job Status Loading**: Job tracking page loads initial status via `copilot/job/{job-id}/status` on page load, applying task status indicators to the flow chart before live polling begins +- **Live Polling Drain**: Live APIs (session/task/job) use a drain model — clients continue polling until receiving terminal `*Closed` or `*NotFound` errors, ensuring all buffered responses are consumed. Each entity has a configurable countdown (1 minute normal, 5 seconds in test mode) after closing, during which new tokens can still join and read history +- **Closed State Management**: Sessions, tasks, and jobs use `LiveEntityState` with token-based lifecycle management — entities transition through open/closed states with countdown periods before cleanup, preventing lost data +- **Test Mode API**: `copilot/test/installJobsEntry` endpoint (test mode only) for dynamically installing job entries during testing +- **Job Workflow Engine**: Composable work tree execution supporting sequential, parallel, loop, and conditional (alt) work patterns +- **Task Selection UI**: Combo box in the portal to select and run tasks within an active session +- **Tool Registration**: Custom job tools (e.g. `job_boolean_true`, `job_prepare_document`) are automatically registered with Copilot sessions +- **Flow Chart Renderers**: Job tracking page uses Mermaid for declarative flowchart rendering +- **Job Status Tracking**: Live polling of job execution status with visual status bar (RUNNING/SUCCEEDED/FAILED/CANCELED) and Stop Job button +- **Flow Chart Status Indicators**: Running tasks display a green triangle indicator; succeeded tasks display a green tick indicator; failed tasks display a red cross indicator on the flow chart +- **Task Inspection**: Clicking a TaskNode in the flow chart opens a tab control showing session responses for that task's sessions +- **Job Preview Mode**: Job tracking page supports a preview mode (no jobId) showing the flow chart without tracking, with no Stop Job button and "JOB: PREVIEW" status +- **Job-Created Tasks**: Jobs create tasks in managed session mode; `startTask` manages driving session creation internally. Task live API provides real-time session updates with `sessionId` and `isDriving` fields +- **Task Decision Reporting**: `taskDecision` callback reports all driving session decisions with categorized prefixes (`[SESSION STARTED]`, `[OPERATION]`, `[CRITERIA]`, `[AVAILABILITY]`, `[SESSION CRASHED]`, `[TASK SUCCEEDED]`, `[TASK FAILED]`, `[DECISION]`) as User message blocks in the driving session tab +- **Driving Session Consolidation**: All driving sessions for a task are consolidated into a single "Driving" tab; when a driving session is replaced (e.g., due to crash retry), the new session reuses the same tab and renderer +- **Borrowing Session Mode**: Tasks can run with an externally-provided session (borrowing mode); crashes in borrowing mode fail immediately without retry +- **Managed Session Mode**: Tasks in managed mode create their own sessions — single model mode reuses one session, multiple models mode creates ephemeral sessions per mission +- **Task Stopping via TaskStoppedError**: When a task is stopped, `guardedSendRequest` (a private helper wrapping `session.sendRequest`) throws `TaskStoppedError` if the task's `stopped` flag is set. Retry logic in `sendMonitoredPrompt` checks `this.stopped` after catching non-TaskStoppedError exceptions and converts them to `TaskStoppedError`, ensuring immediate task termination without unnecessary session replacement. Stopping a job also stops all its running tasks and emits a `jobCanceled` callback. +- **Separated Retry Budgets**: Driving session crash retries use `entry.drivingSessionRetries` with multi-model fallback per driving mission; task session crash retries are per-call (5 max in `sendMonitoredPrompt`); criteria retries are per failure action loop. A crash exhausting its per-call budget during a criteria retry loop is treated as a failed iteration rather than killing the task diff --git a/.github/Agent/package.json b/.github/Agent/package.json new file mode 100644 index 00000000..f50db9e9 --- /dev/null +++ b/.github/Agent/package.json @@ -0,0 +1,22 @@ +{ + "name": "copilot-agent-workspace", + "version": "1.0.0", + "license": "Apache-2.0", + "private": true, + "type": "module", + "workspaces": [ + "packages/*" + ], + "scripts": { + "compile": "yarn workspaces run build", + "testStart": "yarn workspace copilot-portal testStart", + "testExecute": "yarn workspace copilot-portal testExecute", + "build": "yarn compile && yarn testStart && yarn testExecute", + "portal": "yarn workspace copilot-portal start", + "portal-for-test": "yarn workspace copilot-portal launch-for-test" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "typescript": "^5.7.3" + } +} diff --git a/.github/Agent/packages/CopilotPortal/assets/flowChartMermaid.js b/.github/Agent/packages/CopilotPortal/assets/flowChartMermaid.js new file mode 100644 index 00000000..295399b4 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/flowChartMermaid.js @@ -0,0 +1,291 @@ +// ---- Mermaid Flow Chart Renderer ---- +// Renders a ChartGraph using Mermaid.js to generate a flowchart diagram. + +function mermaidGetHintKey(hint) { + return Array.isArray(hint) ? hint[0] : hint; +} + +function mermaidBuildDefinition(chart) { + const lines = ["graph TD"]; + + // Define nodes + for (const node of chart.nodes) { + const hintKey = mermaidGetHintKey(node.hint); + const id = `N${node.id}`; + const label = node.label || ""; + + switch (hintKey) { + case "TaskNode": + lines.push(` ${id}["${label}"]`); + break; + case "CondNode": + lines.push(` ${id}{{"${label}"}}`); + break; + case "ParBegin": + case "ParEnd": + lines.push(` ${id}[" "]`); + break; + case "AltEnd": + lines.push(` ${id}[" "]`); + break; + case "CondBegin": + lines.push(` ${id}[" "]`); + break; + case "CondEnd": + lines.push(` ${id}{" "}`); + break; + case "LoopEnd": + lines.push(` ${id}((" "))`); + break; + default: + lines.push(` ${id}["${label || hintKey}"]`); + break; + } + } + + // Define edges + for (const node of chart.nodes) { + if (node.arrows) { + for (const arrow of node.arrows) { + const from = `N${node.id}`; + const to = `N${arrow.to}`; + if (arrow.label) { + lines.push(` ${from} -->|"${arrow.label}"| ${to}`); + } else { + lines.push(` ${from} --> ${to}`); + } + } + } + } + + // Apply styles + for (const node of chart.nodes) { + const hintKey = mermaidGetHintKey(node.hint); + const id = `N${node.id}`; + + switch (hintKey) { + case "TaskNode": + lines.push(` style ${id} fill:#dbeafe,stroke:#3b82f6,stroke-width:2px,color:#1e3a5f`); + break; + case "CondNode": + lines.push(` style ${id} fill:#fef9c3,stroke:#eab308,stroke-width:2px,color:#92400e`); + break; + case "ParBegin": + case "ParEnd": + lines.push(` style ${id} fill:#222,stroke:#222,stroke-width:2px,color:#222,font-size:0px,min-width:40px,min-height:6px,padding:0px`); + break; + case "AltEnd": + lines.push(` style ${id} fill:#fce7f3,stroke:#db2777,stroke-width:2px,color:#fce7f3,font-size:0px,min-width:40px,min-height:6px,padding:0px`); + break; + case "CondBegin": + lines.push(` style ${id} fill:#fef9c3,stroke:#eab308,stroke-width:2px,color:#fef9c3,font-size:0px,min-width:40px,min-height:6px,padding:0px`); + break; + case "CondEnd": + lines.push(` style ${id} fill:#fef9c3,stroke:#eab308,stroke-width:2px,color:#fef9c3,font-size:0px,padding:0px`); + break; + case "LoopEnd": + lines.push(` style ${id} fill:#f3f4f6,stroke:#9ca3af,stroke-width:2px,color:#f3f4f6,font-size:0px,padding:0px`); + break; + } + } + + return lines.join("\n"); +} + +async function renderFlowChartMermaid(chart, container, onInspect) { + const definition = mermaidBuildDefinition(chart); + + // Build maps for TaskNode/CondNode click handling and workId lookup + const taskNodeIds = []; + const nodeIdToWorkId = {}; + for (const node of chart.nodes) { + const hintKey = mermaidGetHintKey(node.hint); + if (hintKey === "TaskNode" || hintKey === "CondNode") { + const nid = `N${node.id}`; + taskNodeIds.push(nid); + // hint is [hintKey, workIdInJob] + if (Array.isArray(node.hint) && node.hint.length >= 2) { + nodeIdToWorkId[nid] = node.hint[1]; + } + } + } + + // Render with Mermaid + const { svg } = await mermaid.render("mermaid-chart", definition); + container.innerHTML = ""; + container.innerHTML = svg; + + // Fix SVG viewBox to ensure nothing is clipped (especially emoji indicators on leftmost nodes) + const svgEl = container.querySelector("svg"); + if (svgEl) { + // Get the bounding box of the entire SVG content + const bbox = svgEl.getBBox(); + const padding = 24; + const vbX = bbox.x - padding; + const vbY = bbox.y - padding; + const vbW = bbox.width + padding * 2; + const vbH = bbox.height + padding * 2; + svgEl.setAttribute("viewBox", `${vbX} ${vbY} ${vbW} ${vbH}`); + // Set explicit dimensions so the SVG doesn't collapse or scale unexpectedly + svgEl.style.width = `${vbW}px`; + svgEl.style.height = `${vbH}px`; + svgEl.style.minWidth = `${vbW}px`; + svgEl.style.minHeight = `${vbH}px`; + } + + // Ctrl+Scroll zoom on the chart container + let zoomLevel = 1; + const zoomMin = 0.2; + const zoomMax = 3; + const zoomStep = 0.1; + container.addEventListener("wheel", (e) => { + if (!e.ctrlKey) return; + e.preventDefault(); + const delta = e.deltaY > 0 ? -zoomStep : zoomStep; + zoomLevel = Math.min(zoomMax, Math.max(zoomMin, zoomLevel + delta)); + const svgInner = container.querySelector("svg"); + if (svgInner) { + svgInner.style.transformOrigin = "top left"; + svgInner.style.transform = `scale(${zoomLevel})`; + } + }, { passive: false }); + + // Track currently selected TaskNode/CondNode + let currentSelectedGroup = null; + let currentSelectedOriginalStrokeWidth = null; + let currentSelectedWorkId = null; + + // Map workId -> DOM group for status updates + const workIdToGroup = {}; + const workIdToTextEl = {}; + + // Add click handlers for TaskNode/CondNode elements + for (const nodeId of taskNodeIds) { + const nodeEl = container.querySelector(`[id^="flowchart-${nodeId}-"]`); + if (!nodeEl) continue; + const group = nodeEl.closest("g.node") || nodeEl; + group.style.cursor = "pointer"; + + const workId = nodeIdToWorkId[nodeId]; + if (workId !== undefined) { + workIdToGroup[workId] = group; + const textEl = group.querySelector(".nodeLabel") || group.querySelector("text"); + workIdToTextEl[workId] = textEl; + } + + group.addEventListener("click", () => { + const wid = nodeIdToWorkId[nodeId]; + const shapeEl = group.querySelector("rect, polygon, circle, ellipse, path"); + + if (currentSelectedGroup === group) { + // Unselect + if (shapeEl && currentSelectedOriginalStrokeWidth !== null) { + shapeEl.style.strokeWidth = currentSelectedOriginalStrokeWidth; + } + currentSelectedGroup = null; + currentSelectedOriginalStrokeWidth = null; + currentSelectedWorkId = null; + if (onInspect) onInspect(null); + } else { + // Unselect previous + if (currentSelectedGroup) { + const prevShape = currentSelectedGroup.querySelector("rect, polygon, circle, ellipse, path"); + if (prevShape && currentSelectedOriginalStrokeWidth !== null) { + prevShape.style.strokeWidth = currentSelectedOriginalStrokeWidth; + } + } + // Select new + currentSelectedOriginalStrokeWidth = shapeEl ? (shapeEl.style.strokeWidth || shapeEl.getAttribute("style")?.match(/stroke-width:\s*([^;]+)/)?.[1] || getComputedStyle(shapeEl).strokeWidth) : null; + if (shapeEl) { + shapeEl.style.strokeWidth = "5px"; + } + currentSelectedGroup = group; + currentSelectedWorkId = wid; + if (onInspect) onInspect(wid); + } + }); + } + + // Return controller for status updates + return { + // Helper: position an indicator at the center of the left border of the node + _positionIndicator(indicator, group) { + // Try to find the shape element (rect, polygon, etc.) of the node + const shape = group.querySelector("rect, polygon, circle, ellipse, path"); + if (shape) { + const shapeBBox = shape.getBBox ? shape.getBBox() : null; + if (shapeBBox) { + // Center of the left border, but outside + indicator.setAttribute("x", String(shapeBBox.x - 20)); + indicator.setAttribute("y", String(shapeBBox.y + shapeBBox.height / 2 + 5)); + return; + } + } + // Fallback: use text position + const textEl = workIdToTextEl[parseInt(indicator.getAttribute("data-work-id"))]; + if (textEl) { + const textBBox = textEl.getBBox ? textEl.getBBox() : null; + if (textBBox) { + indicator.setAttribute("x", String(textBBox.x - 20)); + indicator.setAttribute("y", String(textBBox.y + textBBox.height * 0.8)); + } + } + }, + // Set a node to running state (green triangle emoji) + setRunning(workId) { + const group = workIdToGroup[workId]; + if (!group) return; + this._clearIndicator(workId); + const indicator = document.createElementNS("http://www.w3.org/2000/svg", "text"); + indicator.textContent = "\u25B6\uFE0F"; // ▶️ green triangle emoji + indicator.setAttribute("fill", "#22c55e"); + indicator.setAttribute("font-size", "14"); + indicator.setAttribute("class", "task-status-indicator"); + indicator.setAttribute("data-work-id", String(workId)); + const parent = group; + parent.insertBefore(indicator, parent.firstChild); + this._positionIndicator(indicator, group); + }, + // Set a node to completed state (green tick emoji) + setCompleted(workId) { + const group = workIdToGroup[workId]; + if (!group) return; + this._clearIndicator(workId); + const indicator = document.createElementNS("http://www.w3.org/2000/svg", "text"); + indicator.textContent = "\u2705"; // ✅ green tick emoji + indicator.setAttribute("font-size", "14"); + indicator.setAttribute("class", "task-status-indicator"); + indicator.setAttribute("data-work-id", String(workId)); + const parent = group; + parent.insertBefore(indicator, parent.firstChild); + this._positionIndicator(indicator, group); + }, + // Set a node to failed state (red cross emoji) + setFailed(workId) { + const group = workIdToGroup[workId]; + if (!group) return; + this._clearIndicator(workId); + const indicator = document.createElementNS("http://www.w3.org/2000/svg", "text"); + indicator.textContent = "\u274C"; // ❌ red cross emoji + indicator.setAttribute("font-size", "14"); + indicator.setAttribute("class", "task-status-indicator"); + indicator.setAttribute("data-work-id", String(workId)); + const parent = group; + parent.insertBefore(indicator, parent.firstChild); + this._positionIndicator(indicator, group); + }, + _clearIndicator(workId) { + const svgEl = container.querySelector("svg"); + if (svgEl) { + const existing = svgEl.querySelectorAll(`.task-status-indicator[data-work-id="${workId}"]`); + existing.forEach(el => el.remove()); + } + }, + get inspectedWorkId() { + return currentSelectedWorkId; + }, + }; +} + +// Export as global +window.renderFlowChartMermaid = renderFlowChartMermaid; diff --git a/.github/Agent/packages/CopilotPortal/assets/index.css b/.github/Agent/packages/CopilotPortal/assets/index.css new file mode 100644 index 00000000..889fd9cb --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/index.css @@ -0,0 +1,214 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + overflow: hidden; +} + +/* ---- Setup UI ---- */ +#setup-ui { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 12px; +} + +#setup-ui label { + display: flex; + flex-direction: column; + gap: 4px; + font-weight: bold; + min-width: 400px; +} + +#setup-ui select, +#setup-ui input[type="text"] { + padding: 6px 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 4px; +} + +#setup-buttons { + display: flex; + justify-content: space-between; + align-items: center; + min-width: 400px; +} + +#setup-buttons-left { + display: flex; + gap: 8px; +} + +#setup-ui button { + padding: 8px 24px; + font-size: 14px; + cursor: pointer; + border: 1px solid #888; + border-radius: 4px; + background: #f8f8f8; +} + +#setup-ui button:hover:not(:disabled) { + background: #e8e8e8; +} + +#setup-ui button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ---- Running Jobs List ---- */ +#running-jobs-list { + min-width: 400px; + max-height: 300px; + overflow-y: auto; + margin-top: 8px; +} + +.running-job-item { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 0; + border-bottom: 1px solid #eee; +} + +.running-job-view-btn { + padding: 3px 10px; + font-size: 12px; + cursor: pointer; + border: 1px solid #888; + border-radius: 4px; + background: #f8f8f8; + flex-shrink: 0; +} + +.running-job-view-btn:hover { + background: #e8e8e8; +} + +.running-job-name { + font-weight: bold; + flex: 1; +} + +.running-job-status { + font-size: 12px; + padding: 2px 6px; + border-radius: 3px; + flex-shrink: 0; +} + +.running-job-status-running { color: #0a0; } +.running-job-status-succeeded { color: #080; } +.running-job-status-failed { color: #c00; } +.running-job-status-canceled { color: #888; } + +.running-job-time { + font-size: 12px; + color: #666; + flex-shrink: 0; +} + +/* ---- Session UI ---- */ +#session-ui { + display: none; + flex-direction: column; + height: 100%; + width: 100%; +} + +#session-part { + flex: 1; + min-height: 0; +} + +#resize-bar { + height: 6px; + background: #ccc; + cursor: ns-resize; + flex-shrink: 0; +} + +#resize-bar:hover { + background: #aaa; +} + +#request-part { + display: flex; + flex-direction: column; + flex-shrink: 0; + height: 300px; + min-height: 100px; + padding: 8px; +} + +#task-row { + display: flex; + align-items: center; + margin-bottom: 8px; +} + +#task-row label { + font-weight: bold; + white-space: nowrap; +} + +#task-row select { + flex: 1; + padding: 4px 8px; + font-size: 13px; + border: 1px solid #ccc; + border-radius: 4px; +} + +#request-textarea { + flex: 1; + resize: none; + padding: 8px; + font-family: monospace; + font-size: 13px; + border: 1px solid #ccc; + border-radius: 4px; + min-height: 0; +} + +#request-buttons { + display: flex; + justify-content: space-between; + padding-top: 6px; +} + +#request-buttons-left { + display: flex; + gap: 6px; +} + +#request-buttons button { + padding: 6px 16px; + font-size: 13px; + cursor: pointer; + border: 1px solid #888; + border-radius: 4px; + background: #f8f8f8; +} + +#request-buttons button:hover:not(:disabled) { + background: #e8e8e8; +} + +#request-buttons button:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/.github/Agent/packages/CopilotPortal/assets/index.html b/.github/Agent/packages/CopilotPortal/assets/index.html new file mode 100644 index 00000000..aaa9a724 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/index.html @@ -0,0 +1,52 @@ + + + + + Copilot Portal + + + + + + +
+ + +
+
+ + +
+ +
+
+
+ +
+
+
+
+
+ + +
+ +
+
+ + +
+ +
+
+
+ + + + diff --git a/.github/Agent/packages/CopilotPortal/assets/index.js b/.github/Agent/packages/CopilotPortal/assets/index.js new file mode 100644 index 00000000..d7ca4fc5 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/index.js @@ -0,0 +1,358 @@ +import { SessionResponseRenderer } from "./sessionResponse.js"; + +// ---- State ---- +let sessionId = null; +let livePollingActive = false; +let sendEnabled = false; + +// ---- DOM references ---- +const setupUi = document.getElementById("setup-ui"); +const sessionUi = document.getElementById("session-ui"); +const modelSelect = document.getElementById("model-select"); +const workingDirInput = document.getElementById("working-dir"); +const startButton = document.getElementById("start-button"); +const jobsButton = document.getElementById("jobs-button"); +const refreshButton = document.getElementById("refresh-button"); +const runningJobsList = document.getElementById("running-jobs-list"); +const sessionPart = document.getElementById("session-part"); +const requestTextarea = document.getElementById("request-textarea"); +const sendButton = document.getElementById("send-button"); +const stopServerButton = document.getElementById("stop-server-button"); +const closeSessionButton = document.getElementById("close-session-button"); +const resizeBar = document.getElementById("resize-bar"); +const requestPart = document.getElementById("request-part"); +const taskSelect = document.getElementById("task-select"); + +// ---- Session Response Renderer ---- +const sessionRenderer = new SessionResponseRenderer(sessionPart); + +// ---- Setup: Load models and defaults ---- + +async function loadModels() { + try { + const res = await fetch("/api/copilot/models"); + const data = await res.json(); + const models = data.models.sort((a, b) => a.name.localeCompare(b.name)); + modelSelect.innerHTML = ""; + for (const m of models) { + const option = document.createElement("option"); + option.value = m.id; + option.textContent = m.name; + modelSelect.appendChild(option); + } + // Default to gpt-5.2 + const defaultOption = modelSelect.querySelector('option[value="gpt-5.2"]'); + if (defaultOption) { + defaultOption.selected = true; + } + startButton.disabled = false; + } catch (err) { + console.error("Failed to load models:", err); + } +} + +async function initWorkingDir() { + try { + const res = await fetch("/api/config"); + const config = await res.json(); + const repoRoot = config.repoRoot; + const params = new URLSearchParams(window.location.search); + const project = params.get("project"); + if (project) { + const sep = repoRoot.includes("\\") ? "\\" : "/"; + const parentIdx = Math.max(repoRoot.lastIndexOf("/"), repoRoot.lastIndexOf("\\")); + const parentDir = parentIdx > 0 ? repoRoot.substring(0, parentIdx) : repoRoot; + workingDirInput.value = parentDir + sep + project; + } else { + workingDirInput.value = repoRoot; + } + } catch (err) { + console.error("Failed to load config:", err); + } +} + +// ---- Jobs Navigation ---- + +jobsButton.addEventListener("click", () => { + const wd = workingDirInput.value; + window.location.href = `/jobs.html?wb=${encodeURIComponent(wd)}`; +}); + +// ---- Running Jobs List ---- + +async function loadRunningJobs() { + try { + const res = await fetch("/api/copilot/job/running"); + const data = await res.json(); + runningJobsList.innerHTML = ""; + if (data.jobs && data.jobs.length > 0) { + for (const job of data.jobs) { + const item = document.createElement("div"); + item.className = "running-job-item"; + + const viewBtn = document.createElement("button"); + viewBtn.textContent = "View"; + viewBtn.className = "running-job-view-btn"; + viewBtn.addEventListener("click", () => { + window.open(`/jobTracking.html?jobName=${encodeURIComponent(job.jobName)}&jobId=${encodeURIComponent(job.jobId)}`, "_blank"); + }); + item.appendChild(viewBtn); + + const nameSpan = document.createElement("span"); + nameSpan.className = "running-job-name"; + nameSpan.textContent = job.jobName; + item.appendChild(nameSpan); + + const statusSpan = document.createElement("span"); + statusSpan.className = `running-job-status running-job-status-${job.status.toLowerCase()}`; + statusSpan.textContent = job.status; + item.appendChild(statusSpan); + + const timeSpan = document.createElement("span"); + timeSpan.className = "running-job-time"; + timeSpan.textContent = new Date(job.startTime).toLocaleString(); + item.appendChild(timeSpan); + + runningJobsList.appendChild(item); + } + } + } catch (err) { + console.error("Failed to load running jobs:", err); + } +} + +refreshButton.addEventListener("click", () => { + loadRunningJobs(); +}); + +// ---- Start Session ---- + +startButton.addEventListener("click", async () => { + const modelId = modelSelect.value; + if (!modelId) return; + + startButton.disabled = true; + try { + const res = await fetch(`/api/copilot/session/start/${encodeURIComponent(modelId)}`, { + method: "POST", + body: workingDirInput.value, + }); + const data = await res.json(); + if (data.error) { + alert("Failed to start session: " + data.error); + startButton.disabled = false; + return; + } + sessionId = data.sessionId; + setupUi.style.display = "none"; + sessionUi.style.display = "flex"; + sendEnabled = true; + sendButton.disabled = false; + startLivePolling(); + } catch (err) { + alert("Failed to start session: " + err); + startButton.disabled = false; + } +}); + +// ---- Live Polling ---- + +function startLivePolling() { + livePollingActive = true; + pollLive(); +} + +async function pollLive() { + // Acquire a token for this polling session + let token; + try { + const tokenRes = await fetch("/api/token"); + const tokenData = await tokenRes.json(); + token = tokenData.token; + } catch (err) { + console.error("Failed to acquire token:", err); + return; + } + + while (livePollingActive) { + try { + const res = await fetch(`/api/copilot/session/${encodeURIComponent(sessionId)}/live/${encodeURIComponent(token)}`); + if (!livePollingActive) break; + const data = await res.json(); + if (!livePollingActive) break; + + if (data.error === "HttpRequestTimeout") { + // Timeout, just resend + continue; + } + if (data.error === "SessionNotFound" || data.error === "SessionClosed") { + livePollingActive = false; + break; + } + // Batch response: process all responses in the batch + if (data.responses) { + for (const r of data.responses) { + if (r.sessionError) { + console.error("Session error:", r.sessionError); + continue; + } + if (r.callback) { + processCallback(r); + } + } + } + } catch (err) { + if (!livePollingActive) break; + // Network error, retry after brief delay + await new Promise(r => setTimeout(r, 500)); + } + } +} + +// ---- Process Callbacks ---- + +function processCallback(data) { + const cb = data.callback; + if (cb === "onGeneratedUserPrompt") { + sessionRenderer.addUserMessage(data.prompt, "Task"); + return cb; + } + const result = sessionRenderer.processCallback(data); + if (result === "onIdle") { + setSendEnabled(true); + } + return result; +} + +// ---- Send / Request ---- + +function setSendEnabled(enabled) { + sendEnabled = enabled; + sendButton.disabled = !enabled; + sessionRenderer.setAwaiting(!enabled); +} + +async function sendRequest() { + if (!sendEnabled) return; + const text = requestTextarea.value.trim(); + if (!text) return; + + setSendEnabled(false); + requestTextarea.value = ""; + + const selectedTask = taskSelect.value; + + if (selectedTask) { + // Start a task with the selected task name + try { + await fetch(`/api/copilot/task/start/${encodeURIComponent(selectedTask)}/session/${encodeURIComponent(sessionId)}`, { + method: "POST", + body: text, + }); + } catch (err) { + console.error("Failed to start task:", err); + setSendEnabled(true); + } + } else { + // Talk to the session directly + sessionRenderer.addUserMessage(text); + try { + await fetch(`/api/copilot/session/${encodeURIComponent(sessionId)}/query`, { + method: "POST", + body: text, + }); + } catch (err) { + console.error("Failed to send query:", err); + setSendEnabled(true); + } + } +} + +sendButton.addEventListener("click", sendRequest); + +requestTextarea.addEventListener("keydown", (e) => { + if (e.ctrlKey && e.key === "Enter") { + e.preventDefault(); + sendRequest(); + } +}); + +// ---- Stop / Close ---- + +function closeWindow() { + // Chrome blocks window.close() for tabs not opened by script. + // Try to close, then fall back to a "safe to close" page. + window.close(); + // If we're still here after a short delay, the browser blocked window.close(). + setTimeout(() => { + document.title = "Session Ended"; + document.body.innerHTML = '

Session ended — you may close this tab.

'; + }, 200); +} + +async function closeSession() { + livePollingActive = false; + try { + await fetch(`/api/copilot/session/${encodeURIComponent(sessionId)}/stop`); + } catch (err) { + // Ignore errors during shutdown + } + closeWindow(); +} + +closeSessionButton.addEventListener("click", closeSession); + +stopServerButton.addEventListener("click", async () => { + livePollingActive = false; + try { + await fetch(`/api/copilot/session/${encodeURIComponent(sessionId)}/stop`); + await fetch("/api/stop"); + } catch (err) { + // Ignore errors during shutdown + } + closeWindow(); +}); + +// ---- Resize Bar ---- + +let resizing = false; + +resizeBar.addEventListener("mousedown", (e) => { + e.preventDefault(); + resizing = true; +}); + +document.addEventListener("mousemove", (e) => { + if (!resizing) return; + const containerRect = sessionUi.getBoundingClientRect(); + const newRequestHeight = containerRect.bottom - e.clientY - resizeBar.offsetHeight / 2; + const clamped = Math.max(100, Math.min(newRequestHeight, containerRect.height - 100)); + requestPart.style.height = clamped + "px"; +}); + +document.addEventListener("mouseup", () => { + resizing = false; +}); + +// ---- Init ---- + +async function loadTasks() { + try { + const res = await fetch("/api/copilot/task"); + const data = await res.json(); + // Keep the (none) option; add tasks + for (const t of data.tasks) { + const option = document.createElement("option"); + option.value = t.name; + option.textContent = t.name; + taskSelect.appendChild(option); + } + } catch (err) { + console.error("Failed to load tasks:", err); + } +} + +loadModels(); +initWorkingDir(); +loadTasks(); +loadRunningJobs(); diff --git a/.github/Agent/packages/CopilotPortal/assets/jobTracking.css b/.github/Agent/packages/CopilotPortal/assets/jobTracking.css new file mode 100644 index 00000000..669f8338 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/jobTracking.css @@ -0,0 +1,174 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + overflow: hidden; + display: flex; +} + +/* ---- Layout ---- */ +#left-part { + flex: 1; + min-width: 0; + overflow: auto; + display: flex; + flex-direction: column; +} + +#resize-bar { + width: 6px; + background: #ccc; + cursor: ew-resize; + flex-shrink: 0; +} + +#resize-bar:hover { + background: #aaa; +} + +#right-part { + width: 800px; + min-width: 200px; + display: flex; + flex-direction: column; + overflow: auto; +} + +/* ---- Job Status Bar ---- */ +#job-status-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: #2d2d2d; + border-bottom: 1px solid #444; + flex-shrink: 0; +} + +#job-status-label { + font-size: 20px; + font-weight: bold; + color: #fff; + padding: 6px 16px; +} + +#job-status-label.job-status-running { + color: #60a5fa; +} + +#job-status-label.job-status-succeeded { + color: #4ade80; +} + +#job-status-label.job-status-failed { + color: #f87171; +} + +#job-status-label.job-status-canceled { + color: #fbbf24; +} + +#stop-job-button { + font-size: 18px; + padding: 8px 24px; + cursor: pointer; + background: #dc2626; + color: #fff; + border: none; + border-radius: 4px; +} + +#stop-job-button:hover:not(:disabled) { + background: #b91c1c; +} + +#stop-job-button:disabled { + opacity: 0.4; + cursor: default; +} + +/* ---- Chart Container ---- */ +#chart-container { + flex: 1; + overflow: auto; + display: flex; + align-items: flex-start; + justify-content: flex-start; + padding: 16px; +} + +#chart-container > svg { + margin: auto; +} + +/* ---- Job Part ---- */ +#job-part { + height: 100%; + overflow: auto; + display: flex; + flex-direction: column; +} + +/* ---- Session Response Part ---- */ +#session-response-part { + padding: 0; + height: 100%; + overflow: auto; +} + +/* ---- Tab Control ---- */ +.tab-container { + display: flex; + flex-direction: column; + height: 100%; +} + +.tab-headers { + display: flex; + flex-wrap: wrap; + gap: 2px; + border-bottom: 2px solid #444; + padding: 6px 16px 0; + flex-shrink: 0; + background: #2d2d2d; +} + +.tab-header-btn { + padding: 8px 16px; + border: 1px solid #444; + border-bottom: none; + border-radius: 4px 4px 0 0; + background: #3d3d3d; + color: #aaa; + cursor: pointer; + font-size: 14px; +} + +.tab-header-btn:hover { + background: #4d4d4d; +} + +.tab-header-btn.active { + background: #1e1e1e; + color: #fff; + border-color: #666; + font-weight: bold; +} + +.tab-content { + flex: 1; + overflow: auto; + padding: 8px; +} + +.session-renderer-container { + height: 100%; + overflow: auto; +} diff --git a/.github/Agent/packages/CopilotPortal/assets/jobTracking.html b/.github/Agent/packages/CopilotPortal/assets/jobTracking.html new file mode 100644 index 00000000..ab989be4 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/jobTracking.html @@ -0,0 +1,25 @@ + + + + + Copilot Portal - Job Tracking + + + + + + + + +
+
+
+
+
+
+
+ + + + + diff --git a/.github/Agent/packages/CopilotPortal/assets/jobTracking.js b/.github/Agent/packages/CopilotPortal/assets/jobTracking.js new file mode 100644 index 00000000..741218a1 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/jobTracking.js @@ -0,0 +1,573 @@ +import { SessionResponseRenderer } from "./sessionResponse.js"; + +// ---- Redirect if no jobName ---- +const params = new URLSearchParams(window.location.search); +const jobName = params.get("jobName"); +const jobId = params.get("jobId"); +if (!jobName) { + window.location.href = "/index.html"; +} + +const isPreviewMode = !jobId; + +// ---- DOM references ---- +const jobPart = document.getElementById("job-part"); +const sessionResponsePart = document.getElementById("session-response-part"); +const resizeBar = document.getElementById("resize-bar"); +const rightPart = document.getElementById("right-part"); + +// ---- State ---- +let chartController = null; // returned from renderFlowChartMermaid +let jobStatus = isPreviewMode ? "PREVIEW" : "RUNNING"; // PREVIEW | RUNNING | SUCCEEDED | FAILED | CANCELED +let jobStopped = false; + +// Map: workId -> { taskId, sessions: Map, attemptCount } +const workIdData = {}; + +// Currently inspected workId (null = none) +let inspectedWorkId = null; + +// ---- JSON display state ---- +let jobToRender = null; +let chartToRender = null; +let jsonPre = null; + +// ---- Session response tab control ---- +let tabContainer = null; + +// ---- Job status bar elements ---- +let statusLabel = null; +let stopJobButton = null; + +function createStatusBar() { + const bar = document.createElement("div"); + bar.id = "job-status-bar"; + + statusLabel = document.createElement("div"); + statusLabel.id = "job-status-label"; + statusLabel.textContent = `JOB: ${jobStatus}`; + bar.appendChild(statusLabel); + + if (!isPreviewMode) { + stopJobButton = document.createElement("button"); + stopJobButton.id = "stop-job-button"; + stopJobButton.textContent = "Stop Job"; + stopJobButton.addEventListener("click", async () => { + if (jobStopped) return; + try { + await fetch(`/api/copilot/job/${encodeURIComponent(jobId)}/stop`, { method: "POST" }); + jobStopped = true; + jobStatus = "CANCELED"; + updateStatusLabel(); + stopJobButton.disabled = true; + } catch (err) { + console.error("Failed to stop job:", err); + } + }); + bar.appendChild(stopJobButton); + } + + return bar; +} + +function updateStatusLabel() { + if (statusLabel) { + statusLabel.textContent = `JOB: ${jobStatus}`; + statusLabel.className = ""; + statusLabel.classList.add(`job-status-${jobStatus.toLowerCase()}`); + } + if (stopJobButton) { + stopJobButton.disabled = jobStatus !== "RUNNING"; + } +} + +// ---- Session Response Part Management ---- + +function switchTabForWork(workId, sessionId) { + const data = workIdData[workId]; + if (!data) return; + if (data.activeTabSessionId === sessionId) return; + data.activeTabSessionId = sessionId; + + if (inspectedWorkId !== workId || !tabContainer) return; + + const tabHeaders = tabContainer.querySelector(".tab-headers"); + if (!tabHeaders) return; + + for (const btn of tabHeaders.querySelectorAll(".tab-header-btn")) { + btn.classList.toggle("active", btn.dataset.sessionId === sessionId); + } + for (const [sid, sInfo] of data.sessions) { + sInfo.div.style.display = sid === sessionId ? "block" : "none"; + } +} + +function showJsonView() { + sessionResponsePart.innerHTML = ""; + if (tabContainer) { + tabContainer = null; + } + jsonPre = document.createElement("pre"); + jsonPre.style.padding = "8px"; + jsonPre.textContent = JSON.stringify({ job: jobToRender, chart: chartToRender }, undefined, 4); + sessionResponsePart.appendChild(jsonPre); +} + +function showTaskSessionTabs(workId) { + sessionResponsePart.innerHTML = ""; + const data = workIdData[workId]; + if (!data) { + sessionResponsePart.textContent = "No session data for this task."; + return; + } + + // Reset active tab tracking so the first switchTabForWork always applies + data.activeTabSessionId = null; + + tabContainer = document.createElement("div"); + tabContainer.className = "tab-container"; + + const tabHeaders = document.createElement("div"); + tabHeaders.className = "tab-headers"; + tabContainer.appendChild(tabHeaders); + + const tabContent = document.createElement("div"); + tabContent.className = "tab-content"; + tabContainer.appendChild(tabContent); + + sessionResponsePart.appendChild(tabContainer); + + // Ensure "Driving" tab always appears first + const sortedEntries = [...data.sessions.entries()].sort((a, b) => { + if (a[1].name === "Driving") return -1; + if (b[1].name === "Driving") return 1; + return 0; + }); + + for (const [sessionId, sessionInfo] of sortedEntries) { + const tabBtn = document.createElement("button"); + tabBtn.className = "tab-header-btn"; + tabBtn.textContent = sessionInfo.name; + tabBtn.dataset.sessionId = sessionId; + tabBtn.addEventListener("click", () => { + switchTabForWork(workId, tabBtn.dataset.sessionId); + }); + tabHeaders.appendChild(tabBtn); + + // Append the session's div to tab content (hidden by default) + sessionInfo.div.style.display = "none"; + tabContent.appendChild(sessionInfo.div); + } + + // Activate the first tab + const firstEntry = sortedEntries[0]; + if (firstEntry) { + switchTabForWork(workId, firstEntry[0]); + } +} + +function refreshSessionResponsePart() { + if (inspectedWorkId !== null) { + showTaskSessionTabs(inspectedWorkId); + } else { + showJsonView(); + } +} + +function onInspect(workId) { + inspectedWorkId = workId; + refreshSessionResponsePart(); +} + +// ---- Live Polling Helpers ---- + +async function pollLive(url, handler, shouldStop) { + const terminalPattern = /^(Session|Task|Jobs?)(Closed|NotFound)$/; + + // Acquire a token for this polling session + let token; + try { + const tokenRes = await fetch("/api/token"); + const tokenData = await tokenRes.json(); + token = tokenData.token; + } catch (err) { + console.error("Failed to acquire token:", err); + return; + } + + while (true) { + if (shouldStop()) break; + try { + const res = await fetch(`/api/${url}/${encodeURIComponent(token)}`); + const data = await res.json(); + if (data.error === "HttpRequestTimeout") continue; + if (data.error === "ParallelCallNotSupported") { + await new Promise(r => setTimeout(r, 100)); + continue; + } + // Terminal: Closed or NotFound — drain complete + if (data.error && terminalPattern.test(data.error)) break; + // Batch response: process all responses in the batch + if (data.responses) { + for (const r of data.responses) { + handler(r); + } + } + } catch (err) { + console.error(`Poll error for ${url}:`, err); + break; + } + } +} + +// ---- Session Polling ---- + +function startSessionPolling(sessionId, workId) { + const data = workIdData[workId]; + if (!data) return; + + const sessionInfo = data.sessions.get(sessionId); + if (!sessionInfo) return; + + pollLive( + `copilot/session/${encodeURIComponent(sessionId)}/live`, + (response) => { + if (response.sessionError) { + console.error(`Session ${sessionId} error:`, response.sessionError); + return; + } + if (response.callback) { + sessionInfo.renderer.processCallback(response); + } + }, + () => false // Always drain to server terminal response + ); +} + +// ---- Task Polling ---- + +function startTaskPolling(taskId, workId) { + const data = workIdData[workId]; + if (!data) return; + data.taskPollingActive = true; + + pollLive( + `copilot/task/${encodeURIComponent(taskId)}/live`, + (response) => { + if (response.taskError) { + console.error(`Task ${taskId} error:`, response.taskError); + return; + } + const cb = response.callback; + + if (cb === "taskSessionStarted") { + const sessionId = response.sessionId; + const isDriving = response.isDriving; + if (sessionId) { + if (isDriving) { + // Consolidate all driving sessions into one "Driving" tab + if (data.drivingSessionId) { + const oldInfo = data.sessions.get(data.drivingSessionId); + if (oldInfo) oldInfo.active = false; + data.sessions.delete(data.drivingSessionId); + } + + let div, renderer; + if (data.drivingDiv) { + // Reuse existing driving renderer + div = data.drivingDiv; + renderer = data.drivingRenderer; + } else { + // First driving session + div = document.createElement("div"); + div.className = "session-renderer-container"; + renderer = new SessionResponseRenderer(div); + data.drivingDiv = div; + data.drivingRenderer = renderer; + } + + data.drivingSessionId = sessionId; + data.sessions.set(sessionId, { + name: "Driving", + renderer, + div, + active: true, + }); + + // Update tab UI if inspecting + if (inspectedWorkId === workId && tabContainer) { + const tabHeaders = tabContainer.querySelector(".tab-headers"); + const tabContent = tabContainer.querySelector(".tab-content"); + if (tabHeaders && tabContent) { + const existingDrivingBtn = [...tabHeaders.querySelectorAll(".tab-header-btn")] + .find(btn => btn.textContent === "Driving"); + if (existingDrivingBtn) { + // Update session ID reference on existing button + existingDrivingBtn.dataset.sessionId = sessionId; + } else { + // First driving session — insert tab at front + const tabBtn = document.createElement("button"); + tabBtn.className = "tab-header-btn"; + tabBtn.textContent = "Driving"; + tabBtn.dataset.sessionId = sessionId; + tabBtn.addEventListener("click", () => { + switchTabForWork(workId, tabBtn.dataset.sessionId); + }); + tabHeaders.insertBefore(tabBtn, tabHeaders.firstChild); + div.style.display = "none"; + tabContent.insertBefore(div, tabContent.firstChild); + } + } + } + + startSessionPolling(sessionId, workId); + } else { + // Task session — each gets its own tab + data.attemptCount = (data.attemptCount || 0) + 1; + const name = `Attempt #${data.attemptCount}`; + + const div = document.createElement("div"); + div.className = "session-renderer-container"; + const renderer = new SessionResponseRenderer(div); + + data.sessions.set(sessionId, { + name, + renderer, + div, + active: true, + }); + + // Add new tab header without switching to it + if (inspectedWorkId === workId && tabContainer) { + const tabHeaders = tabContainer.querySelector(".tab-headers"); + const tabContent = tabContainer.querySelector(".tab-content"); + if (tabHeaders && tabContent) { + const tabBtn = document.createElement("button"); + tabBtn.className = "tab-header-btn"; + tabBtn.textContent = name; + tabBtn.dataset.sessionId = sessionId; + tabBtn.addEventListener("click", () => { + switchTabForWork(workId, sessionId); + }); + tabHeaders.appendChild(tabBtn); + + div.style.display = "none"; + tabContent.appendChild(div); + } + } + + startSessionPolling(sessionId, workId); + } + } + } else if (cb === "taskSessionStopped") { + const sessionId = response.sessionId; + if (sessionId) { + const sInfo = data.sessions.get(sessionId); + if (sInfo) { + sInfo.active = false; + } + } + } else if (cb === "taskSucceeded" || cb === "taskFailed") { + // Mark all sessions inactive + for (const [, sInfo] of data.sessions) { + sInfo.active = false; + } + data.taskPollingActive = false; + } else if (cb === "taskDecision") { + // Create a "User" message block in the driving session's renderer + if (data.drivingRenderer) { + data.drivingRenderer.addUserMessage(response.reason, "TaskDecision"); + } + } + }, + () => false // Always drain to server terminal response + ); +} + +// ---- Job Polling ---- + +function startJobPolling() { + pollLive( + `copilot/job/${encodeURIComponent(jobId)}/live`, + (response) => { + if (response.jobError) { + jobStatus = "FAILED"; + updateStatusLabel(); + return; + } + const cb = response.callback; + + if (cb === "workStarted") { + const workId = response.workId; + const taskId = response.taskId; + + // Clear previous data if task runs again (loop scenario) + if (workIdData[workId]) { + const oldData = workIdData[workId]; + // Stop all old session polling + for (const [, sInfo] of oldData.sessions) { + sInfo.active = false; + } + oldData.taskPollingActive = false; + } + + workIdData[workId] = { + taskId, + sessions: new Map(), + attemptCount: 0, + taskPollingActive: false, + activeTabSessionId: null, + }; + + // If the restarted work is currently inspected, clear tabs + if (inspectedWorkId === workId) { + refreshSessionResponsePart(); + } + + // Update flow chart - running indicator + if (chartController) { + chartController.setRunning(workId); + } + + // Start task polling + if (taskId) { + startTaskPolling(taskId, workId); + } + } else if (cb === "workStopped") { + const workId = response.workId; + const succeeded = response.succeeded; + + if (chartController) { + if (succeeded) { + chartController.setCompleted(workId); + } else { + chartController.setFailed(workId); + } + } + } else if (cb === "jobSucceeded") { + jobStatus = "SUCCEEDED"; + updateStatusLabel(); + } else if (cb === "jobFailed") { + jobStatus = "FAILED"; + updateStatusLabel(); + } else if (cb === "jobCanceled") { + jobStatus = "CANCELED"; + updateStatusLabel(); + } + }, + () => false // Always drain to server terminal response + ); +} + +// ---- Load initial job status ---- +async function loadInitialJobStatus() { + if (isPreviewMode || !jobId) return; + try { + const res = await fetch(`/api/copilot/job/${encodeURIComponent(jobId)}/status`); + const data = await res.json(); + if (data.error) return; + + // Update job status + if (data.status === "Succeeded") { + jobStatus = "SUCCEEDED"; + } else if (data.status === "Failed") { + jobStatus = "FAILED"; + } else if (data.status === "Canceled") { + jobStatus = "CANCELED"; + jobStopped = true; + } else { + jobStatus = "RUNNING"; + } + updateStatusLabel(); + + // Update task statuses on the chart + if (data.tasks && chartController) { + for (const task of data.tasks) { + if (task.status === "Running") { + chartController.setRunning(task.workIdInJob); + } else if (task.status === "Succeeded") { + chartController.setCompleted(task.workIdInJob); + } else if (task.status === "Failed") { + chartController.setFailed(task.workIdInJob); + } + } + } + } catch (err) { + console.error("Failed to load initial job status:", err); + } +} + +// ---- Load job data and render chart ---- +async function loadJobData() { + try { + const res = await fetch("/api/copilot/job"); + const data = await res.json(); + const jobDefinition = data.jobs[jobName]; + if (!jobDefinition) { + jobPart.textContent = `Job "${jobName}" not found.`; + return; + } + + const chart = data.chart && data.chart[jobName]; + if (!chart || !chart.nodes || chart.nodes.length === 0) { + jobPart.textContent = "No chart data available for this job."; + return; + } + + jobToRender = jobDefinition; + chartToRender = chart; + + // Show JSON initially + showJsonView(); + + // Create status bar + const statusBar = createStatusBar(); + + // Build job part layout + jobPart.innerHTML = ""; + jobPart.appendChild(statusBar); + + const chartContainer = document.createElement("div"); + chartContainer.id = "chart-container"; + jobPart.appendChild(chartContainer); + + // Render with Mermaid + chartController = await renderFlowChartMermaid(chart, chartContainer, isPreviewMode ? () => {} : onInspect); + + // Load initial job status before starting live polling + await loadInitialJobStatus(); + + // Start job live polling only when not in preview mode + if (!isPreviewMode) { + startJobPolling(); + } + } catch (err) { + console.error("Failed to load job data:", err); + jobPart.textContent = "Failed to load job data."; + } +} + +// ---- Resize bar (horizontal) ---- +let resizing = false; + +resizeBar.addEventListener("mousedown", (e) => { + resizing = true; + e.preventDefault(); +}); + +document.addEventListener("mousemove", (e) => { + if (!resizing) return; + const totalWidth = document.body.clientWidth; + const barWidth = resizeBar.offsetWidth; + let rightWidth = totalWidth - e.clientX - barWidth / 2; + if (rightWidth < 200) rightWidth = 200; + if (rightWidth > totalWidth - 200) rightWidth = totalWidth - 200; + rightPart.style.width = rightWidth + "px"; +}); + +document.addEventListener("mouseup", () => { + resizing = false; +}); + +// ---- Init ---- +loadJobData(); diff --git a/.github/Agent/packages/CopilotPortal/assets/jobs.css b/.github/Agent/packages/CopilotPortal/assets/jobs.css new file mode 100644 index 00000000..35dcbbfc --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/jobs.css @@ -0,0 +1,172 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; + overflow: hidden; + display: flex; +} + +/* ---- Layout ---- */ +#left-part { + flex: 1; + min-width: 0; + overflow: auto; +} + +#resize-bar { + width: 6px; + background: #ccc; + cursor: ew-resize; + flex-shrink: 0; +} + +#resize-bar:hover { + background: #aaa; +} + +#right-part { + width: 800px; + min-width: 200px; + display: flex; + flex-direction: column; + overflow: auto; +} + +/* ---- Matrix Part ---- */ +#matrix-part { + padding: 16px; + height: 100%; +} + +#matrix-table { + width: 100%; + height: 100%; + border-collapse: collapse; + table-layout: fixed; +} + +#matrix-table th, +#matrix-table td { + border: 1px solid #e0e0e0; + padding: 12px; + text-align: center; + vertical-align: middle; +} + +#matrix-table .matrix-title { + text-align: center; + font-size: 34px; + font-weight: bold; + padding: 16px; +} + +#matrix-table .matrix-stop-cell { + text-align: right; + padding: 16px; +} + +#matrix-table .matrix-stop-cell button { + padding: 8px 20px; + font-size: 16px; + cursor: pointer; + border: 1px solid #888; + border-radius: 4px; + background: #f8f8f8; +} + +#matrix-table .matrix-stop-cell button:hover { + background: #e8e8e8; +} + +#matrix-table .matrix-keyword { + font-weight: bold; + font-size: 30px; + white-space: nowrap; +} + +#matrix-table td button { + width: 100%; + padding: 12px 10px; + font-size: 28px; + cursor: pointer; + border: 1px solid #888; + border-radius: 4px; + background: #f8f8f8; +} + +#matrix-table td button:hover { + background: #e8e8e8; +} + +.matrix-job-btn.selected { + font-weight: bold; + border-color: #357abd; + background: #e8f0fe; +} + +.matrix-job-btn.selected:hover { + background: #d0e0f8; +} + +.matrix-automate-btn { + background: #f0f0f0 !important; + border-color: #aaa !important; + font-style: italic; +} + +/* ---- User Input Part ---- */ +#user-input-part { + display: flex; + flex-direction: column; + height: 100%; + padding: 8px; +} + +#user-input-textarea { + flex: 1; + resize: none; + padding: 8px; + font-family: monospace; + font-size: 13px; + border: 1px solid #ccc; + border-radius: 4px; + min-height: 0; +} + +#user-input-buttons { + display: flex; + justify-content: flex-end; + padding-top: 6px; + gap: 8px; +} + +#user-input-buttons button { + padding: 8px 24px; + font-size: 14px; + cursor: pointer; + border: 1px solid #888; + border-radius: 4px; + background: #f8f8f8; +} + +#user-input-buttons button:hover:not(:disabled) { + background: #e8e8e8; +} + +#user-input-buttons button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +#user-input-textarea:disabled { + opacity: 0.5; + cursor: not-allowed; + background: #f0f0f0; +} diff --git a/.github/Agent/packages/CopilotPortal/assets/jobs.html b/.github/Agent/packages/CopilotPortal/assets/jobs.html new file mode 100644 index 00000000..bea49cbb --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/jobs.html @@ -0,0 +1,25 @@ + + + + + Copilot Portal - Jobs + + + +
+
+
+
+
+
+ +
+ + +
+
+
+ + + + diff --git a/.github/Agent/packages/CopilotPortal/assets/jobs.js b/.github/Agent/packages/CopilotPortal/assets/jobs.js new file mode 100644 index 00000000..dd7949b4 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/jobs.js @@ -0,0 +1,196 @@ +// ---- Redirect if no working directory ---- +const params = new URLSearchParams(window.location.search); +const workingDir = params.get("wb"); +if (!workingDir) { + window.location.href = "/index.html"; +} + +// ---- State ---- +let selectedJobName = null; +let jobsData = null; + +// ---- DOM references ---- +const matrixPart = document.getElementById("matrix-part"); +const userInputTextarea = document.getElementById("user-input-textarea"); +const startJobButton = document.getElementById("start-job-button"); +const resizeBar = document.getElementById("resize-bar"); +const leftPart = document.getElementById("left-part"); +const rightPart = document.getElementById("right-part"); + +// ---- Load jobs data ---- +async function loadJobs() { + try { + const res = await fetch("/api/copilot/job"); + jobsData = await res.json(); + renderMatrix(); + } catch (err) { + console.error("Failed to load jobs:", err); + } +} + +// ---- Matrix Rendering ---- +function renderMatrix() { + matrixPart.innerHTML = ""; + const grid = jobsData.grid; + + if (grid.length === 0) { + matrixPart.textContent = "No jobs available."; + return; + } + + const table = document.createElement("table"); + table.id = "matrix-table"; + + // Title row + const titleRow = document.createElement("tr"); + // Count max columns: keyword + max jobs + const maxJobCols = Math.max(...grid.map(row => row.jobs.length)); + const totalCols = 1 + maxJobCols; + const titleCell = document.createElement("th"); + titleCell.colSpan = totalCols - 1; + titleCell.textContent = "Available Jobs"; + titleCell.className = "matrix-title"; + titleRow.appendChild(titleCell); + const stopCell = document.createElement("th"); + stopCell.className = "matrix-stop-cell"; + const stopBtn = document.createElement("button"); + stopBtn.id = "stop-server-button"; + stopBtn.textContent = "Stop Server"; + stopBtn.addEventListener("click", async () => { + try { + await fetch("/api/stop"); + } catch { + // ignore + } + window.close(); + setTimeout(() => { + document.title = "Server Stopped"; + document.body.style.cssText = "display:flex;align-items:center;justify-content:center;height:100vh;width:100vw;font-family:sans-serif;color:#ccc;background:#1e1e1e;margin:0;padding:0;"; + document.body.innerHTML = '

Server stopped — you may close this tab.

'; + }, 200); + }); + stopCell.appendChild(stopBtn); + titleRow.appendChild(stopCell); + table.appendChild(titleRow); + + // Data rows + for (const row of grid) { + const tr = document.createElement("tr"); + + // Keyword column + const kwCell = document.createElement("td"); + kwCell.textContent = row.keyword; + kwCell.className = "matrix-keyword"; + tr.appendChild(kwCell); + + // Job columns + for (let i = 0; i < maxJobCols; i++) { + const jobCell = document.createElement("td"); + if (i < row.jobs.length) { + const col = row.jobs[i]; + if (col !== undefined && col !== null) { + const btn = document.createElement("button"); + btn.textContent = col.name; + btn.className = "matrix-job-btn"; + btn.dataset.jobName = col.jobName; + btn.addEventListener("click", () => onJobButtonClick(btn, col.jobName)); + jobCell.appendChild(btn); + } + // undefined renders an empty cell + } + tr.appendChild(jobCell); + } + + table.appendChild(tr); + } + + matrixPart.appendChild(table); +} + +// ---- Job Selection ---- +function onJobButtonClick(btn, jobName) { + if (selectedJobName === jobName) { + // Deselect + btn.classList.remove("selected"); + selectedJobName = null; + startJobButton.disabled = true; + startJobButton.textContent = "Job Not Selected"; + previewButton.disabled = true; + userInputTextarea.disabled = true; + } else { + // Deselect previous + const prev = matrixPart.querySelector(".matrix-job-btn.selected"); + if (prev) prev.classList.remove("selected"); + // Select new + btn.classList.add("selected"); + selectedJobName = jobName; + startJobButton.disabled = false; + startJobButton.textContent = `Start Job: ${jobName}`; + previewButton.disabled = false; + // Enable text box only when requireUserInput is true + const job = jobsData.jobs[jobName]; + userInputTextarea.disabled = !(job && job.requireUserInput); + } +} + +// ---- Start Job ---- +startJobButton.addEventListener("click", async () => { + if (!selectedJobName) return; + startJobButton.disabled = true; + try { + const userInput = userInputTextarea.value || ""; + const body = workingDir + "\n" + userInput; + const res = await fetch(`/api/copilot/job/start/${encodeURIComponent(selectedJobName)}`, { + method: "POST", + body: body, + }); + const data = await res.json(); + if (data.error) { + alert("Failed to start job: " + data.error); + startJobButton.disabled = false; + return; + } + if (data.jobId) { + const url = `/jobTracking.html?jobName=${encodeURIComponent(selectedJobName)}&jobId=${encodeURIComponent(data.jobId)}`; + window.open(url, "_blank"); + } else { + alert("Failed to start job: unexpected response"); + } + } catch (err) { + alert("Failed to start job: " + err.message); + } + startJobButton.disabled = false; +}); + +// ---- Preview Button ---- +const previewButton = document.getElementById("preview-button"); +previewButton.addEventListener("click", () => { + if (!selectedJobName) return; + const url = `/jobTracking.html?jobName=${encodeURIComponent(selectedJobName)}`; + window.open(url, "_blank"); +}); + +// ---- Resize bar (horizontal) ---- +let resizing = false; + +resizeBar.addEventListener("mousedown", (e) => { + resizing = true; + e.preventDefault(); +}); + +document.addEventListener("mousemove", (e) => { + if (!resizing) return; + const totalWidth = document.body.clientWidth; + const barWidth = resizeBar.offsetWidth; + let rightWidth = totalWidth - e.clientX - barWidth / 2; + if (rightWidth < 200) rightWidth = 200; + if (rightWidth > totalWidth - 200) rightWidth = totalWidth - 200; + rightPart.style.width = rightWidth + "px"; +}); + +document.addEventListener("mouseup", () => { + resizing = false; +}); + +// ---- Init ---- +loadJobs(); diff --git a/.github/Agent/packages/CopilotPortal/assets/messageBlock.css b/.github/Agent/packages/CopilotPortal/assets/messageBlock.css new file mode 100644 index 00000000..4ad97140 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/messageBlock.css @@ -0,0 +1,116 @@ +.message-block { + border: 1px solid #ccc; + margin: 4px 0; + border-radius: 4px; + overflow: hidden; +} + +.message-block-header { + display: flex; + align-items: stretch; + background: #f0f0f0; + font-weight: bold; + font-size: 14px; + user-select: none; +} + +.message-block-header-text { + flex: 1; + padding: 6px 12px; + display: flex; + align-items: center; +} + +.message-block-toggle { + padding: 0 12px; + border: none; + border-left: 1px solid #ddd; + background: #e8e8e8; + font-size: 12px; + cursor: pointer; + white-space: nowrap; + align-self: stretch; +} + +.message-block-toggle:hover { + background: #d8d8d8; +} + +.message-block-header.completed { + cursor: pointer; +} + +.message-block-header.completed:hover { + background: #e0e0e0; +} + +.message-block-body { + padding: 8px 12px; + white-space: pre-wrap; + word-wrap: break-word; + font-family: monospace; + font-size: 13px; + overflow: auto; +} + +.message-block.receiving .message-block-body { + max-height: 150px; +} + +.message-block.completed.collapsed .message-block-body { + display: none; +} + +.message-block-body.markdown-rendered { + white-space: normal; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 14px; +} + +.message-block-body.markdown-rendered pre { + background: #f5f5f5; + padding: 8px 12px; + border-radius: 4px; + overflow-x: auto; + white-space: pre-wrap; + word-wrap: break-word; + font-family: monospace; + font-size: 13px; +} + +.message-block-body.markdown-rendered code { + background: #f0f0f0; + padding: 1px 4px; + border-radius: 3px; + font-family: monospace; + font-size: 13px; +} + +.message-block-body.markdown-rendered pre code { + background: none; + padding: 0; +} + +.message-block-body.markdown-rendered p { + margin: 0.5em 0; +} + +.message-block-body.markdown-rendered ul, +.message-block-body.markdown-rendered ol { + padding-left: 24px; + margin: 0.5em 0; +} + +.message-block-body.markdown-rendered h1, +.message-block-body.markdown-rendered h2, +.message-block-body.markdown-rendered h3, +.message-block-body.markdown-rendered h4 { + margin: 0.5em 0 0.25em 0; +} + +.message-block-body.markdown-rendered blockquote { + border-left: 3px solid #ccc; + padding-left: 12px; + margin: 0.5em 0; + color: #555; +} diff --git a/.github/Agent/packages/CopilotPortal/assets/messageBlock.js b/.github/Agent/packages/CopilotPortal/assets/messageBlock.js new file mode 100644 index 00000000..66090142 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/messageBlock.js @@ -0,0 +1,176 @@ +const MESSAGE_BLOCK_FIELD = "__copilotMessageBlock"; + +function looksLikeMarkdown(text) { + // Check for common markdown patterns + const patterns = [ + /^#{1,6}\s/m, // headings + /\*\*.+?\*\*/, // bold + /\*.+?\*/, // italic + /\[.+?\]\(.+?\)/, // links + /^[-*+]\s/m, // unordered lists + /^\d+\.\s/m, // ordered lists + /^>\s/m, // blockquotes + /```[\s\S]*?```/, // fenced code blocks + /`[^`]+`/, // inline code + /^\|.*\|.*\|/m, // tables + /^---+$/m, // horizontal rules + /!\[.*?\]\(.*?\)/, // images + ]; + let matchCount = 0; + for (const p of patterns) { + if (p.test(text)) matchCount++; + } + return matchCount >= 2; +} + +export class MessageBlock { + #blockType; + #title = ""; + #completed = false; + #collapsed = false; + #rawData = ""; + #showingMarkdown = false; + #divElement; + #headerElement; + #headerTextElement; + #toggleButton; + #bodyElement; + + constructor(blockType) { + this.#blockType = blockType; + this.#title = ""; + this.#completed = false; + this.#collapsed = false; + this.#rawData = ""; + this.#showingMarkdown = false; + + this.#divElement = document.createElement("div"); + this.#divElement.classList.add("message-block", "receiving"); + this.#divElement[MESSAGE_BLOCK_FIELD] = this; + + this.#headerElement = document.createElement("div"); + this.#headerElement.classList.add("message-block-header"); + + this.#headerTextElement = document.createElement("span"); + this.#headerTextElement.classList.add("message-block-header-text"); + this.#headerElement.appendChild(this.#headerTextElement); + + this.#toggleButton = document.createElement("button"); + this.#toggleButton.classList.add("message-block-toggle"); + this.#toggleButton.style.display = "none"; + this.#toggleButton.addEventListener("click", (e) => { + e.stopPropagation(); + this.#toggleView(); + }); + this.#headerElement.appendChild(this.#toggleButton); + + this.#updateHeader(); + this.#divElement.appendChild(this.#headerElement); + + this.#bodyElement = document.createElement("div"); + this.#bodyElement.classList.add("message-block-body"); + this.#divElement.appendChild(this.#bodyElement); + } + + #updateHeader() { + const titlePart = this.#title ? ` (${this.#title})` : ""; + const receiving = this.#completed ? "" : " [receiving...]"; + this.#headerTextElement.textContent = `${this.#blockType}${titlePart}${receiving}`; + } + + #renderMarkdown() { + this.#showingMarkdown = true; + this.#bodyElement.classList.add("markdown-rendered"); + this.#bodyElement.innerHTML = marked.parse(this.#rawData); + this.#toggleButton.textContent = "View Raw Data"; + } + + #renderRawData() { + this.#showingMarkdown = false; + this.#bodyElement.classList.remove("markdown-rendered"); + this.#bodyElement.textContent = this.#rawData; + this.#toggleButton.textContent = "View Markdown"; + } + + #toggleView() { + if (this.#showingMarkdown) { + this.#renderRawData(); + } else { + this.#renderMarkdown(); + } + } + + get title() { + return this.#title; + } + + set title(value) { + this.#title = value; + this.#updateHeader(); + } + + appendData(data) { + this.#rawData += data; + this.#bodyElement.textContent = this.#rawData; + // Auto-scroll to bottom while receiving + this.#bodyElement.scrollTop = this.#bodyElement.scrollHeight; + } + + replaceData(data) { + this.#rawData = data; + this.#bodyElement.textContent = this.#rawData; + this.#bodyElement.scrollTop = this.#bodyElement.scrollHeight; + } + + complete() { + this.#completed = true; + this.#collapsed = false; + this.#updateHeader(); + this.#headerElement.classList.add("completed"); + this.#divElement.classList.remove("receiving"); + this.#divElement.classList.add("completed"); + + // For non-Tool blocks: render markdown if content looks like markdown, show toggle button + if (this.#blockType !== "Tool" && typeof marked !== "undefined") { + this.#toggleButton.style.display = ""; + if (looksLikeMarkdown(this.#rawData)) { + this.#renderMarkdown(); + } else { + this.#renderRawData(); + } + } + + // "User" and "Message" blocks expand, others collapse + // Completing a block should NOT automatically expand or collapse other blocks + const shouldExpand = this.#blockType === "User" || this.#blockType === "Message"; + if (shouldExpand) { + this.#collapsed = false; + this.#divElement.classList.remove("collapsed"); + } else { + this.#collapsed = true; + this.#divElement.classList.add("collapsed"); + } + + this.#headerElement.onclick = () => { + if (!this.#completed) return; + this.#collapsed = !this.#collapsed; + if (this.#collapsed) { + this.#divElement.classList.add("collapsed"); + } else { + this.#divElement.classList.remove("collapsed"); + } + }; + } + + get isCompleted() { + return this.#completed; + } + + get divElement() { + return this.#divElement; + } +} + +export function getMessageBlock(div) { + return div?.[MESSAGE_BLOCK_FIELD]; +} diff --git a/.github/Agent/packages/CopilotPortal/assets/sessionResponse.css b/.github/Agent/packages/CopilotPortal/assets/sessionResponse.css new file mode 100644 index 00000000..470df246 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/sessionResponse.css @@ -0,0 +1,18 @@ +.session-response-container { + position: relative; + height: 100%; + overflow-y: auto; + padding: 8px; +} + +.session-response-awaiting { + display: none; + position: sticky; + bottom: 0; + left: 0; + padding: 4px 8px; + font-size: 12px; + color: #666; + background: rgba(255, 255, 255, 0.9); + pointer-events: none; +} diff --git a/.github/Agent/packages/CopilotPortal/assets/sessionResponse.js b/.github/Agent/packages/CopilotPortal/assets/sessionResponse.js new file mode 100644 index 00000000..50b629a3 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/sessionResponse.js @@ -0,0 +1,146 @@ +import { MessageBlock } from "./messageBlock.js"; + +/** + * SessionResponseRenderer handles rendering of multiple stacking session responses. + * It is a pure rendering component that does not touch any Copilot API. + * Pass an empty div element into the constructor; all child elements are created dynamically. + */ +export class SessionResponseRenderer { + #containerDiv; + #awaitingStatus; + #messageBlocks; // Map keyed by "blockType-blockId" + + /** + * @param {HTMLDivElement} div - An empty div element to render into. + */ + constructor(div) { + this.#containerDiv = div; + this.#containerDiv.classList.add("session-response-container"); + this.#messageBlocks = new Map(); + + // Create awaiting status element + this.#awaitingStatus = document.createElement("div"); + this.#awaitingStatus.classList.add("session-response-awaiting"); + this.#awaitingStatus.textContent = "Awaits responses ..."; + this.#awaitingStatus.style.display = "none"; + this.#containerDiv.appendChild(this.#awaitingStatus); + } + + /** + * Get or create a MessageBlock by type and id. + * @param {string} blockType + * @param {string} blockId + * @returns {MessageBlock} + */ + #getOrCreateBlock(blockType, blockId) { + const key = `${blockType}-${blockId}`; + let block = this.#messageBlocks.get(key); + if (!block) { + block = new MessageBlock(blockType); + this.#messageBlocks.set(key, block); + this.#containerDiv.insertBefore(block.divElement, this.#awaitingStatus); + } + return block; + } + + /** + * Process a callback response from the live polling API. + * This handles all session response types: Reasoning, Message, Tool, and lifecycle events. + * + * @param {object} data - The callback data with a `callback` property. + * @returns {string} The callback name, so the caller can react (e.g. to "onAgentEnd"). + */ + processCallback(data) { + const cb = data.callback; + + // Reasoning + if (cb === "onStartReasoning") { + this.#getOrCreateBlock("Reasoning", data.reasoningId); + } else if (cb === "onReasoning") { + const block = this.#getOrCreateBlock("Reasoning", data.reasoningId); + block.appendData(data.delta); + } else if (cb === "onEndReasoning") { + const block = this.#getOrCreateBlock("Reasoning", data.reasoningId); + block.replaceData(data.completeContent); + block.complete(); + } + + // Message + else if (cb === "onStartMessage") { + this.#getOrCreateBlock("Message", data.messageId); + } else if (cb === "onMessage") { + const block = this.#getOrCreateBlock("Message", data.messageId); + block.appendData(data.delta); + } else if (cb === "onEndMessage") { + const block = this.#getOrCreateBlock("Message", data.messageId); + block.replaceData(data.completeContent); + block.complete(); + } + + // Tool + else if (cb === "onStartToolExecution") { + const block = this.#getOrCreateBlock("Tool", data.toolCallId); + block.title = data.toolName; + block.appendData(data.toolArguments); + block.appendData("\n"); + } else if (cb === "onToolExecution") { + const block = this.#getOrCreateBlock("Tool", data.toolCallId); + block.appendData(data.delta); + } else if (cb === "onEndToolExecution") { + const block = this.#getOrCreateBlock("Tool", data.toolCallId); + if (data.result) { + block.appendData(`\nResult: ${data.result.content}`); + if (data.result.detailedContent) { + block.appendData(`\nDetails: ${data.result.detailedContent}`); + } + } + if (data.error) { + block.appendData(`\nError: ${data.error.message}`); + } + block.complete(); + } + + // Generated user prompt (sent to driving/task session by the engine) + else if (cb === "onGeneratedUserPrompt") { + this.addUserMessage(data.prompt, "Task"); + } + + // Auto-scroll to bottom + this.scrollToBottom(); + + return cb; + } + + /** + * Add a user message block. Appends data and immediately completes it. + * @param {string} text - The user request text. + * @param {string} [title] - Optional title for the message block. + */ + addUserMessage(text, title) { + const userBlock = new MessageBlock("User"); + if (title) { + userBlock.title = title; + } + const userKey = `User-request-${Date.now()}`; + this.#messageBlocks.set(userKey, userBlock); + this.#containerDiv.insertBefore(userBlock.divElement, this.#awaitingStatus); + userBlock.appendData(text); + userBlock.complete(); + this.scrollToBottom(); + } + + /** + * Show or hide the "Awaits responses ..." status. + * @param {boolean} awaiting + */ + setAwaiting(awaiting) { + this.#awaitingStatus.style.display = awaiting ? "block" : "none"; + } + + /** + * Scroll the container to the bottom. + */ + scrollToBottom() { + this.#containerDiv.scrollTop = this.#containerDiv.scrollHeight; + } +} diff --git a/.github/Agent/packages/CopilotPortal/assets/test.html b/.github/Agent/packages/CopilotPortal/assets/test.html new file mode 100644 index 00000000..1f1246d6 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/assets/test.html @@ -0,0 +1,16 @@ + + + + + Test + + + + + diff --git a/.github/Agent/packages/CopilotPortal/package.json b/.github/Agent/packages/CopilotPortal/package.json new file mode 100644 index 00000000..81589f40 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/package.json @@ -0,0 +1,22 @@ +{ + "name": "copilot-portal", + "version": "1.0.0", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "launch-for-test": "node dist/index.js --test", + "testStart": "node test/startServer.mjs", + "testExecute": "node test/runTests.mjs" + }, + "dependencies": { + "@github/copilot-sdk": "^0.1.4" + }, + "devDependencies": { + "@playwright/test": "^1.49.0", + "@types/node": "^22.10.5", + "typescript": "^5.7.3" + } +} diff --git a/.github/Agent/packages/CopilotPortal/src/copilotApi.ts b/.github/Agent/packages/CopilotPortal/src/copilotApi.ts new file mode 100644 index 00000000..4ac3d306 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/src/copilotApi.ts @@ -0,0 +1,254 @@ +import * as http from "node:http"; +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { startSession, type ICopilotSession } from "./copilotSession.js"; +import { + ensureCopilotClient, + stopCoplilotClient, + readBody, + jsonResponse, + getCountDownMs, + createLiveEntityState, + pushLiveResponse, + closeLiveEntity, + waitForLiveResponse, + shutdownLiveEntity, + type LiveEntityState, + type LiveResponse +} from "./sharedApi.js"; + +export { jsonResponse }; + +// ---- Types ---- + +export interface ModelInfo { + name: string; + id: string; + multiplier: number; +} + +interface SessionState { + sessionId: string; + session: ICopilotSession; + entity: LiveEntityState; + sessionError: string | null; + closed: boolean; +} + +// ---- Session Management ---- + +const sessions = new Map(); +let nextSessionId = 1; + +function createSessionCallbacks(state: SessionState) { + return { + onStartReasoning(reasoningId: string) { + pushLiveResponse(state.entity, { callback: "onStartReasoning", reasoningId }); + }, + onReasoning(reasoningId: string, delta: string) { + pushLiveResponse(state.entity, { callback: "onReasoning", reasoningId, delta }); + }, + onEndReasoning(reasoningId: string, completeContent: string) { + pushLiveResponse(state.entity, { callback: "onEndReasoning", reasoningId, completeContent }); + }, + onStartMessage(messageId: string) { + pushLiveResponse(state.entity, { callback: "onStartMessage", messageId }); + }, + onMessage(messageId: string, delta: string) { + pushLiveResponse(state.entity, { callback: "onMessage", messageId, delta }); + }, + onEndMessage(messageId: string, completeContent: string) { + pushLiveResponse(state.entity, { callback: "onEndMessage", messageId, completeContent }); + }, + onStartToolExecution(toolCallId: string, parentToolCallId: string | undefined, toolName: string, toolArguments: string) { + pushLiveResponse(state.entity, { callback: "onStartToolExecution", toolCallId, parentToolCallId, toolName, toolArguments }); + }, + onToolExecution(toolCallId: string, delta: string) { + pushLiveResponse(state.entity, { callback: "onToolExecution", toolCallId, delta }); + }, + onEndToolExecution( + toolCallId: string, + result: { content: string; detailedContent?: string } | undefined, + error: { message: string; code?: string } | undefined + ) { + pushLiveResponse(state.entity, { callback: "onEndToolExecution", toolCallId, result, error }); + }, + onAgentStart(turnId: string) { + pushLiveResponse(state.entity, { callback: "onAgentStart", turnId }); + }, + onAgentEnd(turnId: string) { + pushLiveResponse(state.entity, { callback: "onAgentEnd", turnId }); + }, + onIdle() { + pushLiveResponse(state.entity, { callback: "onIdle" }); + }, + }; +} + +// ---- Helper Functions ---- + +export async function helperGetModels(): Promise { + const client = await ensureCopilotClient(); + const modelList = await client.listModels(); + return modelList.map((m: { name: string; id: string; billing?: { multiplier?: number } }) => ({ + name: m.name, + id: m.id, + multiplier: m.billing?.multiplier ?? 1, + })); +} + +export async function helperSessionStart(modelId: string, workingDirectory?: string): Promise<[ICopilotSession, string]> { + const client = await ensureCopilotClient(); + const sessionId = `session-${nextSessionId++}`; + const entity = createLiveEntityState(getCountDownMs(), () => { + sessions.delete(sessionId); + }); + const state: SessionState = { + sessionId, + session: null as unknown as ICopilotSession, + entity, + sessionError: null, + closed: false, + }; + + const session = await startSession(client, modelId, createSessionCallbacks(state), workingDirectory); + state.session = session; + sessions.set(sessionId, state); + return [session, sessionId]; +} + +export async function helperSessionStop(session: ICopilotSession): Promise { + await session.stop(); + for (const [, state] of sessions) { + if (state.session === session) { + state.closed = true; + closeLiveEntity(state.entity); + break; + } + } +} + +export function helperGetSession(sessionId: string): ICopilotSession | undefined { + const state = sessions.get(sessionId); + return state?.session; +} + +export function helperPushSessionResponse(session: ICopilotSession, response: LiveResponse): void { + for (const [, state] of sessions) { + if (state.session === session) { + pushLiveResponse(state.entity, response); + return; + } + } +} + +export function hasRunningSessions(): boolean { + return [...sessions.values()].some(s => !s.closed); +} + +// ---- API Functions ---- + +export async function apiConfig(req: http.IncomingMessage, res: http.ServerResponse, repoRoot: string): Promise { + jsonResponse(res, 200, { repoRoot }); +} + +export async function apiToken(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const token = crypto.randomUUID(); + jsonResponse(res, 200, { token }); +} + +export function shutdownServer(server: http.Server): void { + console.log("Shutting down..."); + // Shutdown all session live entities + for (const [, state] of sessions) { + shutdownLiveEntity(state.entity); + } + sessions.clear(); + stopCoplilotClient(); + server.close(() => { + process.exit(0); + }); +} + +export async function apiStop(req: http.IncomingMessage, res: http.ServerResponse, server: http.Server): Promise { + jsonResponse(res, 200, {}); + shutdownServer(server); +} + +export async function apiCopilotModels(req: http.IncomingMessage, res: http.ServerResponse): Promise { + try { + const models = await helperGetModels(); + jsonResponse(res, 200, { models }); + } catch (err) { + jsonResponse(res, 500, { error: String(err) }); + } +} + +export async function apiCopilotSessionStart(req: http.IncomingMessage, res: http.ServerResponse, modelId: string): Promise { + const body = await readBody(req); + const workingDirectory = body.trim() || undefined; + try { + // Validate model ID + const models = await helperGetModels(); + if (!models.find(m => m.id === modelId)) { + jsonResponse(res, 200, { error: "ModelIdNotFound" }); + return; + } + + // Validate working directory + if (workingDirectory) { + if (!path.isAbsolute(workingDirectory)) { + jsonResponse(res, 200, { error: "WorkingDirectoryNotAbsolutePath" }); + return; + } + if (!fs.existsSync(workingDirectory)) { + jsonResponse(res, 200, { error: "WorkingDirectoryNotExists" }); + return; + } + } + + const [, sessionId] = await helperSessionStart(modelId, workingDirectory); + jsonResponse(res, 200, { sessionId }); + } catch (err) { + jsonResponse(res, 500, { error: String(err) }); + } +} + +export async function apiCopilotSessionStop(req: http.IncomingMessage, res: http.ServerResponse, sessionId: string): Promise { + const state = sessions.get(sessionId); + if (!state) { + jsonResponse(res, 200, { error: "SessionNotFound" }); + return; + } + state.closed = true; + closeLiveEntity(state.entity); + jsonResponse(res, 200, { result: "Closed" }); +} + +export async function apiCopilotSessionQuery(req: http.IncomingMessage, res: http.ServerResponse, sessionId: string): Promise { + const state = sessions.get(sessionId); + if (!state) { + jsonResponse(res, 200, { error: "SessionNotFound" }); + return; + } + const body = await readBody(req); + // Fire and forget the request - responses come through live polling + state.session.sendRequest(body).catch((err: unknown) => { + state.sessionError = String(err); + pushLiveResponse(state.entity, { sessionError: String(err) }); + }); + jsonResponse(res, 200, {}); +} + +export async function apiCopilotSessionLive(req: http.IncomingMessage, res: http.ServerResponse, sessionId: string, token: string): Promise { + const state = sessions.get(sessionId); + const response = await waitForLiveResponse( + state?.entity, + token, + 5000, + "SessionNotFound", + "SessionClosed", + ); + jsonResponse(res, 200, response); +} diff --git a/.github/Agent/packages/CopilotPortal/src/copilotSession.ts b/.github/Agent/packages/CopilotPortal/src/copilotSession.ts new file mode 100644 index 00000000..795036eb --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/src/copilotSession.ts @@ -0,0 +1,191 @@ +import { CopilotClient, defineTool, type CopilotSession } from "@github/copilot-sdk"; + +export interface ICopilotSession { + get rawSection(): CopilotSession; + sendRequest(message: string, timeout?: number): Promise; + stop(): Promise; +} + +export interface ICopilotSessionCallbacks { + // assistant.reasoning_delta with a new id + onStartReasoning(reasoningId: string): void; + // assistant.reasoning_delta with an existing id + onReasoning(reasoningId: string, delta: string): void; + // assistant.reasoning with an existing id + onEndReasoning(reasoningId: string, completeContent: string): void; + + // assistant.message_delta with a new id + onStartMessage(messageId: string): void; + // assistant.message_delta with an existing id + onMessage(messageId: string, delta: string): void; + // assistant.message with an existing id + onEndMessage(messageId: string, completeContent: string): void; + + // tool.execution_start with a new id + onStartToolExecution( + toolCallId: string, + parentToolCallId: string | undefined, + toolName: string, + toolArguments: string + ): void; + // tool.execution_partial_result with an existing id + onToolExecution(toolCallId: string, delta: string): void; + // tool.execution_complete with an existing id + onEndToolExecution( + toolCallId: string, + result: { content: string; detailedContent?: string } | undefined, + error: { message: string; code?: string } | undefined + ): void; + + // assistant.turn_start + onAgentStart(turnId: string): void; + + // assistant.turn_end + onAgentEnd(turnId: string): void; + + // session.idle + onIdle(): void; +} + +// DOCUMENT: https://github.com/github/copilot-sdk/blob/main/nodejs/README.md +export async function startSession( + client: CopilotClient, + modelId: string, + callback: ICopilotSessionCallbacks, + workingDirectory?: string +): Promise { + const jobTools = [ + defineTool("job_prepare_document", { + description: "When required, use this tool to report a document path that you are about to create or update.", + parameters: { type: "object" as const, properties: { argument: { type: "string", description: "An absolute path of the document" } }, required: ["argument"] }, + handler: async (args: { argument?: string }) => args.argument ?? "", + }), + defineTool("job_boolean_true", { + description: "When required, use this tool to report that a boolean condition is true, with the reason.", + parameters: { type: "object" as const, properties: { argument: { type: "string", description: "The reason" } }, required: ["argument"] }, + handler: async (args: { argument?: string }) => args.argument ?? "", + }), + defineTool("job_boolean_false", { + description: "When required, use this tool to report that a boolean condition is false, with the reason.", + parameters: { type: "object" as const, properties: { argument: { type: "string", description: "The reason" } }, required: ["argument"] }, + handler: async (args: { argument?: string }) => args.argument ?? "", + }), + defineTool("job_prerequisite_failed", { + description: "When required, use this tool to report that a prerequisite check has failed.", + parameters: { type: "object" as const, properties: { argument: { type: "string", description: "The reason" } }, required: ["argument"] }, + handler: async (args: { argument?: string }) => args.argument ?? "", + }), + ]; + + const session = await client.createSession({ + model: modelId, + streaming: true, + workingDirectory, + tools: jobTools, + hooks: { + onPreToolUse: async (input) => { + if (input.toolName === "glob") { + return { + permissionDecision: "deny", + permissionDecisionReason: "Glob does not work on Windows." + } + } + } + } + }); + + const reasoningContentById = new Map(); + const messageContentById = new Map(); + const toolOutputById = new Map(); + + session.on("assistant.turn_start", (event) => { + callback.onAgentStart(event.data.turnId); + }); + + session.on("assistant.turn_end", (event) => { + callback.onAgentEnd(event.data.turnId); + }); + + session.on("assistant.reasoning_delta", (event) => { + const existing = reasoningContentById.get(event.data.reasoningId); + if (existing === undefined) { + reasoningContentById.set(event.data.reasoningId, event.data.deltaContent); + callback.onStartReasoning(event.data.reasoningId); + callback.onReasoning(event.data.reasoningId, event.data.deltaContent); + return; + } + + reasoningContentById.set( + event.data.reasoningId, + existing + event.data.deltaContent + ); + callback.onReasoning(event.data.reasoningId, event.data.deltaContent); + }); + + session.on("assistant.reasoning", (event) => { + reasoningContentById.set(event.data.reasoningId, event.data.content); + callback.onEndReasoning(event.data.reasoningId, event.data.content); + }); + + session.on("assistant.message_delta", (event) => { + const existing = messageContentById.get(event.data.messageId); + if (existing === undefined) { + messageContentById.set(event.data.messageId, event.data.deltaContent); + callback.onStartMessage(event.data.messageId); + callback.onMessage(event.data.messageId, event.data.deltaContent); + return; + } + + messageContentById.set( + event.data.messageId, + existing + event.data.deltaContent + ); + callback.onMessage(event.data.messageId, event.data.deltaContent); + }); + + session.on("assistant.message", (event) => { + messageContentById.set(event.data.messageId, event.data.content); + callback.onEndMessage(event.data.messageId, event.data.content); + }); + + session.on("tool.execution_start", (event) => { + callback.onStartToolExecution( + event.data.toolCallId, + event.data.parentToolCallId, + event.data.toolName, + (event.data.arguments ? JSON.stringify(event.data.arguments, undefined, 2) : "") + ); + }); + + session.on("tool.execution_partial_result", (event) => { + const existing = toolOutputById.get(event.data.toolCallId) ?? ""; + toolOutputById.set(event.data.toolCallId, existing + event.data.partialOutput); + callback.onToolExecution(event.data.toolCallId, event.data.partialOutput); + }); + + session.on("tool.execution_complete", (event) => { + callback.onEndToolExecution( + event.data.toolCallId, + event.data.result, + event.data.error + ); + }); + + session.on("session.idle", () => { + callback.onIdle(); + }); + + return { + get rawSection(): CopilotSession { + return session; + }, + + async sendRequest(message: string, timeout: number = 2147483647): Promise { + await session.sendAndWait({ prompt: message }, timeout); + }, + + async stop(): Promise { + await session.destroy(); + }, + }; +} diff --git a/.github/Agent/packages/CopilotPortal/src/index.ts b/.github/Agent/packages/CopilotPortal/src/index.ts new file mode 100644 index 00000000..0e8155a3 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/src/index.ts @@ -0,0 +1,319 @@ +import * as http from "node:http"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + jsonResponse, + readBody, + setTestMode, +} from "./sharedApi.js"; +import { + apiConfig, + apiStop, + apiToken, + apiCopilotModels, + apiCopilotSessionStart, + apiCopilotSessionStop, + apiCopilotSessionQuery, + apiCopilotSessionLive, + hasRunningSessions, +} from "./copilotApi.js"; +import { + apiTaskList, + apiTaskStart, + apiTaskStop, + apiTaskLive, +} from "./taskApi.js"; +import { + apiJobList, + apiJobRunning, + apiJobStatus, + apiJobStart, + apiJobStop, + apiJobLive, +} from "./jobsApi.js"; +import type { Entry } from "./jobsDef.js"; +import { validateEntry } from "./jobsDef.js"; +import { entry } from "./jobsData.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Parse command-line options +let port = 8888; +let testMode = false; +const args = process.argv.slice(2); +for (let i = 0; i < args.length; i++) { + if (args[i] === "--port" && i + 1 < args.length) { + port = parseInt(args[i + 1], 10); + i++; + } else if (args[i] === "--test") { + testMode = true; + } +} + +if (testMode) { + setTestMode(true); +} + +const mimeTypes: Record = { + ".html": "text/html", + ".css": "text/css", + ".js": "application/javascript", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", +}; + +// assets folder is at packages/CopilotPortal/assets, __dirname is packages/CopilotPortal/dist +const assetsDir = path.resolve(__dirname, "..", "assets"); + +// REPO-ROOT: walk up from __dirname until we find a .git folder +function findRepoRoot(startDir: string): string { + let dir = startDir; + while (true) { + if (fs.existsSync(path.join(dir, ".git"))) { + return dir; + } + const parent = path.dirname(dir); + if (parent === dir) { + // Reached filesystem root without finding .git; fall back to startDir + return startDir; + } + dir = parent; + } +} +const repoRoot = findRepoRoot(__dirname); + +// ---- Entry Management ---- + +let installedEntry: Entry | null = null; + +async function installJobsEntry(entryValue: Entry): Promise { + if (hasRunningSessions()) { + throw new Error("Cannot call installJobsEntry while sessions are running."); + } + installedEntry = entryValue; +} + +function ensureInstalledEntry(): Entry { + if (!installedEntry) { + throw new Error("installJobsEntry has not been called."); + } + return installedEntry; +} + +// ---- Static File Serving ---- + +function serveStaticFile(res: http.ServerResponse, filePath: string): void { + if (!fs.existsSync(filePath)) { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const contentType = mimeTypes[ext] ?? "application/octet-stream"; + const content = fs.readFileSync(filePath); + res.writeHead(200, { "Content-Type": contentType }); + res.end(content); +} + +// ---- API Handler ---- + +async function handleApi(req: http.IncomingMessage, res: http.ServerResponse, apiPath: string): Promise { + // api/test + if (apiPath === "test") { + jsonResponse(res, 200, { message: "Hello, world!" }); + return; + } + + // api/config + if (apiPath === "config") { + await apiConfig(req, res, repoRoot); + return; + } + + // api/stop + if (apiPath === "stop") { + await apiStop(req, res, server); + return; + } + + // api/token + if (apiPath === "token") { + await apiToken(req, res); + return; + } + + // api/copilot/models + if (apiPath === "copilot/models") { + await apiCopilotModels(req, res); + return; + } + + // api/copilot/test/installJobsEntry (only in test mode) + if (apiPath === "copilot/test/installJobsEntry" && testMode) { + const body = await readBody(req); + const entryFilePath = body.trim(); + + // Check if file is in the test folder + const testFolder = path.resolve(__dirname, "..", "test"); + const resolvedPath = path.resolve(entryFilePath); + if (!resolvedPath.startsWith(testFolder + path.sep) && resolvedPath !== testFolder) { + jsonResponse(res, 200, { result: "InvalidatePath", error: `File is not in the test folder: ${resolvedPath}` }); + return; + } + + try { + const fileContent = fs.readFileSync(resolvedPath, "utf-8"); + const entryData = JSON.parse(fileContent); + try { + const validatedEntry = validateEntry(entryData, "testEntry"); + try { + await installJobsEntry(validatedEntry); + jsonResponse(res, 200, { result: "OK" }); + } catch (err) { + jsonResponse(res, 200, { result: "Rejected", error: String(err instanceof Error ? err.message : err) }); + } + } catch (err) { + jsonResponse(res, 200, { result: "InvalidateEntry", error: String(err instanceof Error ? err.message : err) }); + } + } catch (err) { + jsonResponse(res, 200, { result: "InvalidatePath", error: String(err instanceof Error ? err.message : err) }); + } + return; + } + + // api/copilot/session/start/{model-id} + const startMatch = apiPath.match(/^copilot\/session\/start\/(.+)$/); + if (startMatch) { + await apiCopilotSessionStart(req, res, startMatch[1]); + return; + } + + // api/copilot/session/{session-id}/stop + const stopMatch = apiPath.match(/^copilot\/session\/([^\/]+)\/stop$/); + if (stopMatch) { + await apiCopilotSessionStop(req, res, stopMatch[1]); + return; + } + + // api/copilot/session/{session-id}/query + const queryMatch = apiPath.match(/^copilot\/session\/([^\/]+)\/query$/); + if (queryMatch) { + await apiCopilotSessionQuery(req, res, queryMatch[1]); + return; + } + + // api/copilot/session/{session-id}/live/{token} + const liveMatch = apiPath.match(/^copilot\/session\/([^\/]+)\/live\/([^\/]+)$/); + if (liveMatch) { + await apiCopilotSessionLive(req, res, liveMatch[1], liveMatch[2]); + return; + } + + // api/copilot/task (list all tasks) + if (apiPath === "copilot/task") { + await apiTaskList(ensureInstalledEntry(), req, res); + return; + } + + // api/copilot/task/start/{task-name}/session/{session-id} + const taskStartMatch = apiPath.match(/^copilot\/task\/start\/([^\/]+)\/session\/([^\/]+)$/); + if (taskStartMatch) { + await apiTaskStart(ensureInstalledEntry(), req, res, taskStartMatch[1], taskStartMatch[2]); + return; + } + + // api/copilot/task/{task-id}/stop + const taskStopMatch = apiPath.match(/^copilot\/task\/([^\/]+)\/stop$/); + if (taskStopMatch) { + await apiTaskStop(req, res, taskStopMatch[1]); + return; + } + + // api/copilot/task/{task-id}/live/{token} + const taskLiveMatch = apiPath.match(/^copilot\/task\/([^\/]+)\/live\/([^\/]+)$/); + if (taskLiveMatch) { + await apiTaskLive(req, res, taskLiveMatch[1], taskLiveMatch[2]); + return; + } + + // api/copilot/job (list all jobs) + if (apiPath === "copilot/job") { + await apiJobList(ensureInstalledEntry(), req, res); + return; + } + + // api/copilot/job/running + if (apiPath === "copilot/job/running") { + await apiJobRunning(req, res); + return; + } + + // api/copilot/job/start/{job-name} + const jobStartMatch = apiPath.match(/^copilot\/job\/start\/([^\/]+)$/); + if (jobStartMatch) { + await apiJobStart(ensureInstalledEntry(), req, res, jobStartMatch[1]); + return; + } + + // api/copilot/job/{job-id}/status + const jobStatusMatch = apiPath.match(/^copilot\/job\/([^\/]+)\/status$/); + if (jobStatusMatch) { + await apiJobStatus(req, res, jobStatusMatch[1]); + return; + } + + // api/copilot/job/{job-id}/stop + const jobStopMatch = apiPath.match(/^copilot\/job\/([^\/]+)\/stop$/); + if (jobStopMatch) { + await apiJobStop(req, res, jobStopMatch[1]); + return; + } + + // api/copilot/job/{job-id}/live/{token} + const jobLiveMatch = apiPath.match(/^copilot\/job\/([^\/]+)\/live\/([^\/]+)$/); + if (jobLiveMatch) { + await apiJobLive(req, res, jobLiveMatch[1], jobLiveMatch[2]); + return; + } + + jsonResponse(res, 404, { error: "Unknown API endpoint" }); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", `http://localhost:${port}`); + let pathname = url.pathname; + + // API routes + if (pathname.startsWith("/api/")) { + const apiPath = pathname.slice("/api/".length); + handleApi(req, res, apiPath).catch((err) => { + console.error("API error:", err); + jsonResponse(res, 500, { error: String(err) }); + }); + return; + } + + // Website routes + if (pathname === "/") { + pathname = "/index.html"; + } + + const filePath = path.join(assetsDir, pathname); + serveStaticFile(res, filePath); +}); + +// Install the jobs entry (only if not in test mode) +if (!testMode) { + installJobsEntry(entry); +} + +server.listen(port, () => { + console.log(`http://localhost:${port}`); + console.log(`http://localhost:${port}/api/stop`); +}); diff --git a/.github/Agent/packages/CopilotPortal/src/jobsApi.ts b/.github/Agent/packages/CopilotPortal/src/jobsApi.ts new file mode 100644 index 00000000..0e2b0d6a --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/src/jobsApi.ts @@ -0,0 +1,489 @@ +import * as http from "node:http"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import type { ICopilotSession } from "./copilotSession.js"; +import type { Entry, Work, TaskWork, SequentialWork, ParallelWork, LoopWork, AltWork } from "./jobsDef.js"; +import { getModelId } from "./jobsDef.js"; +import { generateChartNodes } from "./jobsChart.js"; +import { + jsonResponse, +} from "./copilotApi.js"; +import { + readBody, + getCountDownMs, + createLiveEntityState, + pushLiveResponse, + closeLiveEntity, + waitForLiveResponse, + shutdownLiveEntity, + type LiveEntityState, + type LiveResponse, +} from "./sharedApi.js"; +import { + type ICopilotTask, + type ICopilotTaskCallback, + startTask, + errorToDetailedString, + registerJobTask, +} from "./taskApi.js"; + +export type { ICopilotTask, ICopilotTaskCallback }; + +// ---- Types ---- + +export interface ICopilotJob { + get runningWorkIds(): number[]; + get status(): "Executing" | "Succeeded" | "Failed"; + stop(): void; +} + +export interface ICopilotJobCallback { + jobSucceeded(): void; + jobFailed(): void; + // Called when this job failed + jobCanceled(): void; + workStarted(workId: number, taskId: string): void; + workStopped(workId: number, succeeded: boolean): void; +} + +// ---- Job State ---- + +interface JobTaskStatus { + workIdInJob: number; + taskId?: string; + status: "Running" | "Succeeded" | "Failed"; +} + +interface JobState { + jobId: string; + jobName: string; + startTime: Date; + job: ICopilotJob; + entity: LiveEntityState; + jobError: string | null; + canceledByStop: boolean; + taskStatuses: Map; +} + +const jobs = new Map(); +let nextJobId = 1; + +// ---- executeWork ---- + +async function executeWork( + entry: Entry, + work: Work, + userInput: string, + workingDirectory: string, + runningIds: Set, + stopped: { readonly value: boolean }, + activeTasks: ICopilotTask[], + callback: ICopilotJobCallback +): Promise { + if (stopped.value) return false; + + switch (work.kind) { + case "Ref": { + const taskWork = work as TaskWork; + const taskName = taskWork.taskId; + + // Determine model override + let taskModelId: string | undefined; + if (taskWork.modelOverride) { + taskModelId = getModelId(taskWork.modelOverride, entry); + } + + // Register task state for live API + const reg = registerJobTask(false); + + runningIds.add(taskWork.workIdInJob); + callback.workStarted(taskWork.workIdInJob, reg.taskId); + + try { + const result = await new Promise((resolve, reject) => { + const taskCallback: ICopilotTaskCallback = { + taskSucceeded() { + reg.pushResponse({ callback: "taskSucceeded" }); + reg.setClosed(); + const crashErr = startedTask?.crashError; + if (crashErr) { reject(crashErr); } else { resolve(true); } + }, + taskFailed() { + reg.pushResponse({ callback: "taskFailed" }); + reg.setClosed(); + const crashErr = startedTask?.crashError; + if (crashErr) { reject(crashErr); } else { resolve(false); } + }, + taskDecision(reason: string) { + reg.pushResponse({ callback: "taskDecision", reason }); + }, + taskSessionStarted(taskSession: ICopilotSession, taskId: string, isDrivingSession: boolean) { + reg.pushResponse({ callback: "taskSessionStarted", sessionId: taskId, isDriving: isDrivingSession }); + }, + taskSessionStopped(taskSession: ICopilotSession, taskId: string, succeeded: boolean) { + reg.pushResponse({ callback: "taskSessionStopped", sessionId: taskId, succeeded }); + }, + }; + + let startedTask: ICopilotTask | null = null; + startTask( + entry, + taskName, + userInput, + undefined, // managed session mode for jobs + false, // not ignoring prerequisites + taskCallback, + taskModelId, + workingDirectory, + () => { } + ).then(t => { + startedTask = t; + reg.setTask(t); + activeTasks.push(t); + }).catch(err => { + reg.setError(errorToDetailedString(err)); + reg.pushResponse({ taskError: errorToDetailedString(err) }); + reg.setClosed(); + reject(err); + }); + }); + + runningIds.delete(taskWork.workIdInJob); + callback.workStopped(taskWork.workIdInJob, result); + return result; + } catch (err) { + runningIds.delete(taskWork.workIdInJob); + callback.workStopped(taskWork.workIdInJob, false); + throw err; // Propagate crash to job level + } + } + case "Seq": { + const seqWork = work as SequentialWork; + for (const w of seqWork.works) { + if (stopped.value) return false; + const result = await executeWork(entry, w, userInput, workingDirectory, runningIds, stopped, activeTasks, callback); + if (!result) return false; + } + return true; + } + case "Par": { + const parWork = work as ParallelWork; + if (parWork.works.length === 0) return true; + const results = await Promise.all( + parWork.works.map(w => executeWork(entry, w, userInput, workingDirectory, runningIds, stopped, activeTasks, callback)) + ); + return results.every(r => r); + } + case "Loop": { + const loopWork = work as LoopWork; + while (true) { + if (stopped.value) return false; + + // Check pre-condition + if (loopWork.preCondition) { + const [expected, condWork] = loopWork.preCondition; + const condResult = await executeWork(entry, condWork, userInput, workingDirectory, runningIds, stopped, activeTasks, callback); + if (condResult !== expected) { + return true; // LoopWork finishes as succeeded + } + } + + // Run body + const bodyResult = await executeWork(entry, loopWork.body, userInput, workingDirectory, runningIds, stopped, activeTasks, callback); + if (!bodyResult) return false; // body fails → LoopWork fails + + // Check post-condition + if (loopWork.postCondition) { + const [expected, condWork] = loopWork.postCondition; + const condResult = await executeWork(entry, condWork, userInput, workingDirectory, runningIds, stopped, activeTasks, callback); + if (condResult !== expected) { + return true; // LoopWork finishes as succeeded + } + // condition matches expected → redo loop + } else { + return true; // No post-condition, loop body ran once successfully + } + } + } + case "Alt": { + const altWork = work as AltWork; + const condResult = await executeWork(entry, altWork.condition, userInput, workingDirectory, runningIds, stopped, activeTasks, callback); + const chosen = condResult ? altWork.trueWork : altWork.falseWork; + if (!chosen) return true; // No chosen work = success + return executeWork(entry, chosen, userInput, workingDirectory, runningIds, stopped, activeTasks, callback); + } + } +} + +// ---- startJob ---- + +export async function startJob( + entry: Entry, + jobName: string, + userInput: string, + workingDirectory: string, + callback: ICopilotJobCallback +): Promise { + const job = entry.jobs[jobName]; + if (!job) { + throw new Error(`Job "${jobName}" not found.`); + } + + let status: "Executing" | "Succeeded" | "Failed" = "Executing"; + let stopped = false; + const runningIds = new Set(); + const activeTasks: ICopilotTask[] = []; + + let canceledByStop = false; + + const copilotJob: ICopilotJob = { + get runningWorkIds() { return Array.from(runningIds); }, + get status() { return status; }, + stop() { + if (stopped) return; + stopped = true; + canceledByStop = true; + status = "Failed"; + for (const task of activeTasks) { + task.stop(); + } + }, + }; + + const executionPromise = (async () => { + try { + const result = await executeWork( + entry, job.work, userInput, workingDirectory, + runningIds, { get value() { return stopped; } }, + activeTasks, callback + ); + if (result) { + status = "Succeeded"; + callback.jobSucceeded(); + } else { + status = "Failed"; + if (canceledByStop) { + callback.jobCanceled(); + } else { + callback.jobFailed(); + } + } + } catch (err) { + if (status === "Executing") { + status = "Failed"; + } + // Stop all running tasks + for (const task of activeTasks) { + task.stop(); + } + if (canceledByStop) { + callback.jobCanceled(); + } else { + callback.jobFailed(); + } + throw err; // Don't consume silently + } + })(); + + (copilotJob as any)._executionPromise = executionPromise; + executionPromise.catch(() => { }); // Prevent unhandled rejection; callers should handle + + return copilotJob; +} + +// ---- Job API Handlers ---- + +export async function apiJobList( + entry: Entry, + req: http.IncomingMessage, + res: http.ServerResponse, +): Promise { + // Build a combined chart from all jobs + const chart: Record> = {}; + for (const [jobName, job] of Object.entries(entry.jobs)) { + chart[jobName] = generateChartNodes(job.work); + } + jsonResponse(res, 200, { grid: entry.grid, jobs: entry.jobs, chart }); +} + +export async function apiJobStart( + entry: Entry, + req: http.IncomingMessage, + res: http.ServerResponse, + jobName: string, +): Promise { + if (!(jobName in entry.jobs)) { + jsonResponse(res, 200, { error: "JobNotFound" }); + return; + } + + const body = await readBody(req); + const lines = body.split("\n"); + const workingDirectory = lines[0].trim(); + const userInput = lines.slice(1).join("\n"); + + if (!workingDirectory || !path.isAbsolute(workingDirectory)) { + jsonResponse(res, 200, { error: "JobNotFound" }); + return; + } + if (!fs.existsSync(workingDirectory)) { + jsonResponse(res, 200, { error: "JobNotFound" }); + return; + } + + try { + const jobId = `job-${nextJobId++}`; + const entity = createLiveEntityState(getCountDownMs()); + const state: JobState = { + jobId, + jobName, + startTime: new Date(), + job: null as unknown as ICopilotJob, + entity, + jobError: null, + canceledByStop: false, + taskStatuses: new Map(), + }; + + const jobCallback: ICopilotJobCallback = { + jobSucceeded() { + pushLiveResponse(entity, { callback: "jobSucceeded" }); + closeLiveEntity(entity); + }, + jobFailed() { + pushLiveResponse(entity, { callback: "jobFailed" }); + closeLiveEntity(entity); + }, + jobCanceled() { + pushLiveResponse(entity, { callback: "jobCanceled" }); + closeLiveEntity(entity); + }, + workStarted(workId: number, taskId: string) { + state.taskStatuses.set(workId, { workIdInJob: workId, taskId, status: "Running" }); + pushLiveResponse(entity, { callback: "workStarted", workId, taskId }); + }, + workStopped(workId: number, succeeded: boolean) { + const existing = state.taskStatuses.get(workId); + if (existing) { + existing.status = succeeded ? "Succeeded" : "Failed"; + delete existing.taskId; + } else { + state.taskStatuses.set(workId, { workIdInJob: workId, status: succeeded ? "Succeeded" : "Failed" }); + } + pushLiveResponse(entity, { callback: "workStopped", workId, succeeded }); + }, + }; + + const copilotJob = await startJob(entry, jobName, userInput, workingDirectory, jobCallback); + state.job = copilotJob; + jobs.set(jobId, state); + + // Catch execution crashes + const execPromise = (copilotJob as any)._executionPromise as Promise | undefined; + if (execPromise) { + execPromise.catch((err: unknown) => { + state.jobError = errorToDetailedString(err); + pushLiveResponse(entity, { jobError: state.jobError }); + closeLiveEntity(entity); + }); + } + + jsonResponse(res, 200, { jobId }); + } catch (err) { + jsonResponse(res, 200, { jobError: errorToDetailedString(err) }); + } +} + +export async function apiJobRunning( + req: http.IncomingMessage, + res: http.ServerResponse, +): Promise { + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + const result: { jobId: string; jobName: string; startTime: Date; status: "Running" | "Succeeded" | "Failed" | "Canceled" }[] = []; + const toDelete: string[] = []; + for (const [id, state] of jobs) { + let status: "Running" | "Succeeded" | "Failed" | "Canceled"; + if (state.canceledByStop) { + status = "Canceled"; + } else { + const s = state.job.status; + status = s === "Executing" ? "Running" : s; + } + if (status === "Running" || state.startTime >= oneHourAgo) { + result.push({ jobId: id, jobName: state.jobName, startTime: state.startTime, status }); + } else { + toDelete.push(id); + } + } + for (const id of toDelete) { jobs.delete(id); } + jsonResponse(res, 200, { jobs: result }); +} + +export async function apiJobStatus( + req: http.IncomingMessage, + res: http.ServerResponse, + jobId: string, +): Promise { + const state = jobs.get(jobId); + if (!state) { + jsonResponse(res, 200, { error: "JobNotFound" }); + return; + } + const now = new Date(); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + let status: "Running" | "Succeeded" | "Failed" | "Canceled"; + if (state.canceledByStop) { + status = "Canceled"; + } else { + const s = state.job.status; + status = s === "Executing" ? "Running" : s; + } + if (status !== "Running" && state.startTime < oneHourAgo) { + jsonResponse(res, 200, { error: "JobNotFound" }); + return; + } + const tasks: { workIdInJob: number; taskId?: string; status: "Running" | "Succeeded" | "Failed" }[] = []; + for (const ts of state.taskStatuses.values()) { + const entry: { workIdInJob: number; taskId?: string; status: "Running" | "Succeeded" | "Failed" } = { workIdInJob: ts.workIdInJob, status: ts.status }; + if (ts.taskId !== undefined) { + entry.taskId = ts.taskId; + } + tasks.push(entry); + } + jsonResponse(res, 200, { jobId, jobName: state.jobName, startTime: state.startTime, status, tasks }); +} + +export async function apiJobStop( + req: http.IncomingMessage, + res: http.ServerResponse, + jobId: string, +): Promise { + const state = jobs.get(jobId); + if (!state) { + jsonResponse(res, 200, { error: "JobNotFound" }); + return; + } + state.canceledByStop = true; + state.job.stop(); + pushLiveResponse(state.entity, { callback: "jobCanceled" }); + closeLiveEntity(state.entity); + jsonResponse(res, 200, { result: "Closed" }); +} + +export async function apiJobLive( + req: http.IncomingMessage, + res: http.ServerResponse, + jobId: string, + token: string, +): Promise { + const state = jobs.get(jobId); + const response = await waitForLiveResponse( + state?.entity, + token, + 5000, + "JobNotFound", + "JobsClosed", + ); + jsonResponse(res, 200, response); +} diff --git a/.github/Agent/packages/CopilotPortal/src/jobsChart.ts b/.github/Agent/packages/CopilotPortal/src/jobsChart.ts new file mode 100644 index 00000000..76237f71 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/src/jobsChart.ts @@ -0,0 +1,167 @@ +import { + Work, + TaskWork, +} from "./jobsDef.js"; + +export type ChartArrow = { + to: number; + loopBack: boolean; + label?: string; +} + +export type ChartNodeHint = + | ["TaskNode" | "CondNode", TaskWork["workIdInJob"]] + | "ParBegin" + | "ParEnd" + | "CondBegin" + | "CondEnd" + | "LoopEnd" + | "AltEnd" + ; + +export type ChartNode = { + id: number; + hint: ChartNodeHint; + label?: string; + arrows?: ChartArrow[]; +} + +export type ChartGraph = { + nodes: ChartNode[]; +} + +function connectNodes(fromNode: ChartNode, toNode: ChartNode, label?: string, loopBack: boolean = false) { + fromNode.arrows = fromNode.arrows || []; + fromNode.arrows.push({ to: toNode.id, label, loopBack }); +} + +function buildConditionNode(condition: Work, nodeId: number[], nodes: ChartNode[]): [ChartNode, ChartNode] { + if (condition.kind === "Seq" && condition.works.length > 1) { + const beginNode: ChartNode = { + id: nodeId[0]++, + hint: "CondBegin", + }; + nodes.push(beginNode); + const nodePairs = buildChart(condition, nodeId, nodes); + const endNode: ChartNode = { + id: nodeId[0]++, + hint: "CondEnd", + }; + nodes.push(endNode); + connectNodes(beginNode, nodePairs[0]); + connectNodes(nodePairs[1], endNode); + return [beginNode, endNode]; + } else { + const condPair = buildChart(condition, nodeId, nodes); + if (condPair[0] === condPair[1]) { + const condNode = condPair[0]; + if (condNode.hint instanceof Array && condNode.hint[0] === "TaskNode") { + condNode.hint[0] = "CondNode"; + } + } + return condPair; + } +} + +function buildChart(work: Work, nodeId: number[], nodes: ChartNode[]): [ChartNode, ChartNode] { + switch (work.kind) { + case "Ref": { + let label = work.taskId; + if (work.modelOverride) { + const modelName = "category" in work.modelOverride ? work.modelOverride.category : work.modelOverride.id; + label = `${label} (${modelName})`; + } + const node: ChartNode = { + id: nodeId[0]++, + hint: ["TaskNode", work.workIdInJob], + label, + } + nodes.push(node); + return [node, node]; + } + case "Seq": { + const nodePairs = work.works.map(w => buildChart(w, nodeId, nodes)); + for (let i = 0; i < nodePairs.length - 1; i++) { + const fromNode = nodePairs[i][1]; + const toNode = nodePairs[i + 1][0]; + connectNodes(fromNode, toNode); + } + return [nodePairs[0][0], nodePairs[nodePairs.length - 1][1]]; + } + case "Par": { + const beginNode: ChartNode = { + id: nodeId[0]++, + hint: "ParBegin", + }; + nodes.push(beginNode); + const nodePairs = work.works.map(w => buildChart(w, nodeId, nodes)); + const endNode: ChartNode = { + id: nodeId[0]++, + hint: "ParEnd", + }; + nodes.push(endNode); + for (const [firstNode, lastNode] of nodePairs) { + connectNodes(beginNode, firstNode); + connectNodes(lastNode, endNode); + } + return [beginNode, endNode]; + } + case "Loop": { + let beginPair: [ChartNode, ChartNode] | undefined; + if (work.preCondition) { + beginPair = buildConditionNode(work.preCondition[1], nodeId, nodes); + } + const bodyNode = buildChart(work.body, nodeId, nodes); + if (!beginPair) { + beginPair = [bodyNode[0], bodyNode[0]]; + } + const endPair = (work.postCondition ? buildConditionNode(work.postCondition[1], nodeId, nodes) : undefined) as [ChartNode, ChartNode]; + const endNode: ChartNode = { + id: nodeId[0]++, + hint: "LoopEnd", + } + nodes.push(endNode); + if (work.preCondition) { + connectNodes(beginPair[1], bodyNode[0], `${work.preCondition[0]}`); + connectNodes(beginPair[1], endNode, `${!work.preCondition[0]}`); + } + if (work.postCondition) { + connectNodes(bodyNode[1], endPair[0]); + connectNodes(endPair[1], beginPair[0], `${work.postCondition[0]}`, true); + connectNodes(endPair[1], endNode, `${!work.postCondition[0]}`); + } else { + connectNodes(bodyNode[1], endNode); + } + return [beginPair[0], endNode]; + } + case "Alt": { + const conditionPair = buildConditionNode(work.condition, nodeId, nodes); + const truePair = (work.trueWork ? buildChart(work.trueWork, nodeId, nodes) : undefined) as [ChartNode, ChartNode]; + const falsePair = (work.falseWork ? buildChart(work.falseWork, nodeId, nodes) : undefined) as [ChartNode, ChartNode]; + const endNode: ChartNode = { + id: nodeId[0]++, + hint: "AltEnd", + }; + nodes.push(endNode); + if (work.trueWork) { + connectNodes(conditionPair[1], truePair[0], "true"); + connectNodes(truePair[1], endNode); + } else { + connectNodes(conditionPair[1], endNode, "true"); + } + if (work.falseWork) { + connectNodes(conditionPair[1], falsePair[0], "false"); + connectNodes(falsePair[1], endNode); + } else { + connectNodes(conditionPair[1], endNode, "false"); + } + return [conditionPair[0], endNode]; + } + } +} + +export function generateChartNodes(work: Work): ChartGraph { + const nodes: ChartNode[] = []; + buildChart(work, [0], nodes); + return { nodes }; +} diff --git a/.github/Agent/packages/CopilotPortal/src/jobsData.ts b/.github/Agent/packages/CopilotPortal/src/jobsData.ts new file mode 100644 index 00000000..c6e0134a --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/src/jobsData.ts @@ -0,0 +1,609 @@ +import type { Entry, Work, TaskWork, Model } from "./jobsDef.js"; +import { retryWithNewSessionCondition, retryFailedCondition, validateEntry } from "./jobsDef.js"; + +function makeRefWork(taskId: string, modelOverride?: Model): TaskWork { + return { + kind: "Ref", + workIdInJob: undefined as never, + taskId, + modelOverride + }; +} + +function makeReviewWork(keyword: "scrum" | "design" | "plan" | "summary"): Work { + return { + kind: "Seq", + works: [{ + kind: "Loop", + postCondition: [false, makeRefWork("review-final-task")], + body: { + kind: "Par", + works: ["reviewers1", "reviewers2", "reviewers3"].map(reviewerKey => makeRefWork(`review-${keyword}-task`, { category: reviewerKey })) + } + }, + makeRefWork(`review-apply-task`)] + } +} + +function makeDocumentWork(jobName: string, keyword: "scrum" | "design" | "plan" | "summary"): Work { + return { + kind: "Seq", + works: [ + makeRefWork(`${jobName}-task`), + makeReviewWork(keyword), + makeRefWork("git-commit") + ] + }; +} + +function makeCodingWork(taskId: string, modelOverride?: Model): Work { + return { + kind: "Seq", + works: [ + makeRefWork(taskId, modelOverride), + makeRefWork("git-commit") + ] + }; +} + +const entryInput: Entry = { + models: { + driving: "gpt-5-mini", + planning: "gpt-5.2", + coding: "gpt-5.2-codex", + reviewers1: "gpt-5.3-codex", + reviewers2: "claude-opus-4.5", + reviewers3: "gemini-3-pro-preview" + }, + drivingSessionRetries: [ + { modelId: "gpt-5-mini", retries: 5 }, + { modelId: "gpt-4.1", retries: 5 }, + { modelId: "gpt-5.1-codex-mini", retries: 3 }, + { modelId: "claude-haiku-4.5", retries: 3 }, + ], + promptVariables: { + reviewerBoardFiles: [ + "## Your Identity", + "You are $task-model, one of the reviewers in the review board.", + "## Reviewer Board Files", + "- gpt -> Copilot_Review_*_GPT.md", + "- claude opus -> Copilot_Review_*_OPUS.md", + "- gemini -> Copilot_Review_*_GEMINI.md", + ], + copilotSdkTips: [ + "NOTE: If you can't find the file, try different ways to make sure, including absolute path, relative path, powershell tool, view tool, slash and backslash, etc.", + "AVOID the glob tool to find any files, it does not work on Windows." + ], + defineRepoRoot: [ + "REPO-ROOT is the root directory of the repo (aka the working directory you are currently in)" + ], + noQuestion: [ + "DO NOT ask user if you can start doing something, especially after you made a plan, always perform your job automatically and proactively til the end." + ], + cppjob: [ + "$defineRepoRoot", + "$copilotSdkTips", + "YOU MUST FOLLOW REPO-ROOT/.github/copilot-instructions.md as a general guideline for all your tasks." + ], + scrum: [ + "Execute the instruction in REPO-ROOT/.github/prompts/0-scrum.prompt.md immediately.", + "$noQuestion" + ], + design: [ + "Execute the instruction in REPO-ROOT/.github/prompts/1-design.prompt.md immediately.", + "$noQuestion" + ], + plan: [ + "Execute the instruction in REPO-ROOT/.github/prompts/2-planning.prompt.md immediately.", + "$noQuestion" + ], + summary: [ + "Execute the instruction in REPO-ROOT/.github/prompts/3-summarizing.prompt.md immediately.", + "$noQuestion" + ], + codingPrefix: [ + "**IMPORT**: It is FORBIDDEN to modify any script files in `REPO-ROOT/.github/Scripts`. If you are getting trouble, the only reason is your code has problem. Fix the code instead of any other kind of working around.", + ], + execute: [ + "$codingPrefix", + "Execute the instruction in REPO-ROOT/.github/prompts/4-execution.prompt.md immediately.", + "$noQuestion" + ], + verify: [ + "$codingPrefix", + "Execute the instruction in REPO-ROOT/.github/prompts/5-verifying.prompt.md immediately.", + "$noQuestion" + ], + refine: [ + "Execute the instruction in REPO-ROOT/.github/prompts/refine.prompt.md immediately.", + "$noQuestion" + ], + review: [ + "Execute the instruction in REPO-ROOT/.github/prompts/review.prompt.md immediately.", + "$noQuestion" + ], + ask: [ + "Execute the instruction in REPO-ROOT/.github/prompts/ask.prompt.md immediately.", + "$noQuestion" + ], + code: [ + "Execute the instruction in REPO-ROOT/.github/prompts/code.prompt.md immediately.", + "$noQuestion" + ], + reportDocument: [ + "YOU MUST use the job_prepare_document tool with an argument: an absolute path of the document you are about to create or update.", + "YOU MUST use the job_prepare_document tool even when you think nothing needs to be updated, it is to make sure you are clear about which document to work on." + ], + reportBoolean: [ + "YOU MUST use either job_boolean_true tool or job_boolean_false tool to answer an yes/no question, with the reason in the argument." + ], + simpleCondition: [ + "$defineRepoRoot", + "$copilotSdkTips", + "$reportBoolean", + "Use job_boolean_true tool if the below condition satisfies, or use job_boolean_false tool if it does not satisfy." + ], + scrumDocReady: [ + "$simpleCondition", + "REPO-ROOT/.github/TaskLogs/Copilot_Scrum.md should exist and its content should not be just a title." + ], + designDocReady: [ + "$simpleCondition", + "REPO-ROOT/.github/TaskLogs/Copilot_Task.md should exist and its content should not be just a title." + ], + planDocReady: [ + "$simpleCondition", + "REPO-ROOT/.github/TaskLogs/Copilot_Planning.md should exist and its content should not be just a title." + ], + execDocReady: [ + "$simpleCondition", + "REPO-ROOT/.github/TaskLogs/Copilot_Execution.md should exist and its content should not be just a title." + ], + execDocVerified: [ + "$simpleCondition", + "REPO-ROOT/.github/TaskLogs/Copilot_Execution.md should exist and it has a `# !!!VERIFIED!!!`." + ], + reviewDocReady: [ + "$simpleCondition", + "REPO-ROOT/.github/TaskLogs/Copilot_Review.md should exist and its content should not be just a title." + ], + reportedDocReady: [ + "$simpleCondition", + "$reported-document should exist and its content should not be just a title." + ], + clearBuildTestLog: [ + "In REPO-ROOT/.github/Scripts, delete both Build.log and Execute.log." + ], + buildSucceededFragment: [ + "REPO-ROOT/.github/Scripts/Build.log must exist and the last several lines shows there is no error" + ], + testPassedFragment: [ + "REPO-ROOT/.github/Scripts/Execute.log must exist and the last several lines shows how many test files and test cases passed" + ] + }, + tasks: { + "scrum-problem-task": { + model: { category: "planning" }, + requireUserInput: true, + prompt: ["$cppjob", "$scrum", "# Problem", "$user-input"], + criteria: { + runConditionInSameSession: false, + condition: ["$scrumDocReady"], + failureAction: retryFailedCondition() + } + }, + "scrum-update-task": { + model: { category: "planning" }, + requireUserInput: true, + prompt: ["$cppjob", "$scrum", "# Update", "$user-input"], + availability: { + condition: ["$scrumDocReady"] + } + }, + "design-problem-next-task": { + model: { category: "planning" }, + requireUserInput: false, + prompt: ["$cppjob", "$design", "# Problem", "Next"], + availability: { + condition: ["$scrumDocReady"] + }, + criteria: { + runConditionInSameSession: false, + condition: ["$designDocReady"], + failureAction: retryFailedCondition() + } + }, + "design-update-task": { + model: { category: "planning" }, + requireUserInput: true, + prompt: ["$cppjob", "$design", "# Update", "$user-input"], + availability: { + condition: ["$designDocReady"] + } + }, + "design-problem-task": { + model: { category: "planning" }, + requireUserInput: true, + prompt: ["$cppjob", "$design", "# Problem", "$user-input"], + criteria: { + runConditionInSameSession: false, + condition: ["$designDocReady"], + failureAction: retryFailedCondition() + } + }, + "plan-problem-task": { + model: { category: "planning" }, + requireUserInput: false, + prompt: ["$cppjob", "$plan", "# Problem"], + availability: { + condition: ["$designDocReady"] + }, + criteria: { + runConditionInSameSession: false, + condition: ["$planDocReady"], + failureAction: retryFailedCondition() + } + }, + "plan-update-task": { + model: { category: "planning" }, + requireUserInput: true, + prompt: ["$cppjob", "$plan", "# Update", "$user-input"], + availability: { + condition: ["$planDocReady"] + } + }, + "summary-problem-task": { + model: { category: "planning" }, + requireUserInput: false, + prompt: ["$cppjob", "$summary", "# Problem"], + availability: { + condition: ["$planDocReady"] + }, + criteria: { + runConditionInSameSession: false, + condition: ["$execDocReady"], + failureAction: retryFailedCondition() + } + }, + "summary-update-task": { + model: { category: "planning" }, + requireUserInput: true, + prompt: ["$cppjob", "$summary", "# Update", "$user-input"], + availability: { + condition: ["$execDocReady"] + } + }, + "execute-task": { + model: { category: "coding" }, + requireUserInput: false, + prompt: ["$cppjob", "$clearBuildTestLog", "$execute"], + availability: { + condition: ["$execDocReady"] + }, + criteria: { + runConditionInSameSession: false, + condition: ["$simpleCondition", "$buildSucceededFragment."], + failureAction: retryFailedCondition() + } + }, + "execute-update-task": { + model: { category: "coding" }, + requireUserInput: true, + prompt: ["$cppjob", "$clearBuildTestLog", "$execute", "# Update", "$user-input"], + availability: { + condition: ["$execDocReady"] + }, + criteria: { + runConditionInSameSession: false, + condition: ["$simpleCondition", "$buildSucceededFragment."], + failureAction: retryFailedCondition() + } + }, + "verify-task": { + model: { category: "coding" }, + requireUserInput: false, + prompt: ["$cppjob", "$clearBuildTestLog", "$verify"], + availability: { + condition: ["$execDocReady"] + }, + criteria: { + runConditionInSameSession: false, + condition: ["$simpleCondition", "$testPassedFragment."], + failureAction: retryFailedCondition() + } + }, + "verify-update-task": { + model: { category: "coding" }, + requireUserInput: true, + prompt: ["$cppjob", "$clearBuildTestLog", "$verify", "# Update", "$user-input"], + availability: { + condition: ["$execDocReady"] + }, + criteria: { + runConditionInSameSession: false, + condition: ["$simpleCondition", "$testPassedFragment."], + failureAction: retryFailedCondition() + } + }, + "scrum-learn-task": { + model: { category: "planning" }, + requireUserInput: false, + prompt: ["$cppjob", "$scrum", "# Learn"], + availability: { + condition: ["$execDocVerified"] + }, + criteria: { + runConditionInSameSession: false, + condition: ["$simpleCondition", "All REPO-ROOT/.github/TaskLogs/Copilot_(Task|Planning|Execution).md must have been deleted."], + failureAction: retryFailedCondition() + } + }, + "refine-task": { + model: { category: "planning" }, + requireUserInput: false, + prompt: ["$cppjob", "$refine"] + }, + "review-scrum-task": { + prompt: ["$cppjob", "$review", "$reportDocument", "# Scrum", "$reviewerBoardFiles"], + requireUserInput: false, + criteria: { + toolExecuted: ["job_prepare_document"], + runConditionInSameSession: false, + condition: ["$reportedDocReady"], + failureAction: retryWithNewSessionCondition() + } + }, + "review-design-task": { + prompt: ["$cppjob", "$review", "$reportDocument", "# Design", "$reviewerBoardFiles"], + requireUserInput: false, + criteria: { + toolExecuted: ["job_prepare_document"], + runConditionInSameSession: false, + condition: ["$reportedDocReady"], + failureAction: retryWithNewSessionCondition() + } + }, + "review-plan-task": { + prompt: ["$cppjob", "$review", "$reportDocument", "# Plan", "$reviewerBoardFiles"], + requireUserInput: false, + criteria: { + toolExecuted: ["job_prepare_document"], + runConditionInSameSession: false, + condition: ["$reportedDocReady"], + failureAction: retryWithNewSessionCondition() + } + }, + "review-summary-task": { + prompt: ["$cppjob", "$review", "$reportDocument", "# Summary", "$reviewerBoardFiles"], + requireUserInput: false, + criteria: { + toolExecuted: ["job_prepare_document"], + runConditionInSameSession: false, + condition: ["$reportedDocReady"], + failureAction: retryWithNewSessionCondition() + } + }, + "review-final-task": { + model: { category: "planning" }, + requireUserInput: false, + prompt: [ + "$cppjob", + "$review", + "# Final", + "$reviewerBoardFiles" + ], + criteria: { + runConditionInSameSession: false, + condition: ["$reviewDocReady"], + failureAction: retryFailedCondition(0) + } + }, + "review-apply-task": { + model: { category: "planning" }, + requireUserInput: false, + prompt: ["$cppjob", "$review", "# Apply", "$reviewerBoardFiles"], + criteria: { + runConditionInSameSession: false, + condition: ["$simpleCondition", "Every REPO-ROOT/.github/TaskLogs/Copilot_Review*.md must have been deleted."], + failureAction: retryFailedCondition() + } + }, + "ask-task": { + model: { category: "planning" }, + requireUserInput: true, + prompt: ["$cppjob", "$ask", "$user-input"] + }, + "code-task": { + model: { category: "planning" }, + requireUserInput: true, + prompt: ["$cppjob", "$code", "$user-input"], + criteria: { + runConditionInSameSession: true, + condition: ["$simpleCondition", "Both conditions satisfy: 1) $buildSucceededFragment; 2) $testPassedFragment."], + failureAction: retryFailedCondition() + } + }, + "git-commit": { + model: { category: "driving" }, + requireUserInput: false, + prompt: [ + "$defineRepoRoot", + "Call REPO-ROOT/.github/Scripts/copilotGitCommit.ps1", + "DO NOT git push." + ] + }, + "git-push": { + model: { category: "driving" }, + requireUserInput: false, + prompt: [ + "`git add` to add all files.", + "`git status` to list affected files.", + "`git commit -am` everything with this message: [BOT] Backup.", + "`git branch` to see the current branch.", + "`git push` to the current branch.", + "DO NOT run multiple commands at once." + ], + criteria: { + runConditionInSameSession: true, + condition: [ + "$simpleCondition", + "`git status` to list file affected, make sure there is nothing uncommited.", + "But it is fine if all uncommited changes are only whitespace related." + ], + failureAction: retryFailedCondition() + } + } + }, + jobs: { + // ---- scrum ---- + "scrum-problem": { work: makeDocumentWork("scrum-problem", "scrum") }, + "scrum-update": { work: makeDocumentWork("scrum-update", "scrum") }, + // ---- task design ---- + "design-problem-next": { work: makeDocumentWork("design-problem-next", "design") }, + "design-update": { work: makeDocumentWork("design-update", "design") }, + "design-problem": { work: makeDocumentWork("design-problem", "design") }, + "plan-problem": { work: makeDocumentWork("plan-problem", "plan") }, + "plan-update": { work: makeDocumentWork("plan-update", "plan") }, + "summary-problem": { work: makeDocumentWork("summary-problem", "summary") }, + "summary-update": { work: makeDocumentWork("summary-update", "summary") }, + // ---- coding ---- + "execute-start": { work: makeCodingWork("execute-task") }, + "execute-update": { work: makeCodingWork("execute-update-task") }, + "verify-start": { work: makeCodingWork("verify-task") }, + "verify-update": { work: makeCodingWork("verify-update-task") }, + // ---- evolution ---- + "scrum-learn": { work: makeCodingWork("scrum-learn-task") }, + "refine": { work: makeCodingWork("refine-task") }, + // ---- review ---- + "scrum-review": { work: { kind: "Seq", works: [makeReviewWork("scrum"), makeRefWork("git-commit")] } }, + "design-review": { work: { kind: "Seq", works: [makeReviewWork("design"), makeRefWork("git-commit")] } }, + "plan-review": { work: { kind: "Seq", works: [makeReviewWork("plan"), makeRefWork("git-commit")] } }, + "summary-review": { work: { kind: "Seq", works: [makeReviewWork("summary"), makeRefWork("git-commit")] } }, + // ---- automation ---- + "design-next-automate": { + work: { + kind: "Seq", works: [ + makeDocumentWork("design-problem-next", "design"), + makeDocumentWork("plan-problem", "plan"), + makeDocumentWork("summary-problem", "summary"), + makeCodingWork("execute-task"), + makeCodingWork("verify-task"), + makeRefWork("git-push") + ] + } + }, + "design-problem-automate": { + work: { + kind: "Seq", works: [ + makeDocumentWork("design-problem", "design"), + makeDocumentWork("plan-problem", "plan"), + makeDocumentWork("summary-problem", "summary"), + makeCodingWork("execute-task"), + makeCodingWork("verify-task"), + makeRefWork("git-push") + ] + } + }, + "plan-automate": { + work: { + kind: "Seq", works: [ + makeDocumentWork("plan-problem", "plan"), + makeDocumentWork("summary-problem", "summary"), + makeCodingWork("execute-task"), + makeCodingWork("verify-task"), + makeRefWork("git-push") + ] + } + }, + "summary-automate": { + work: { + kind: "Seq", works: [ + makeDocumentWork("summary-problem", "summary"), + makeCodingWork("execute-task"), + makeCodingWork("verify-task"), + makeRefWork("git-push") + ] + } + }, + "execute-automate": { + work: { + kind: "Seq", works: [ + makeCodingWork("execute-task"), + makeCodingWork("verify-task"), + makeRefWork("git-push") + ] + } + }, + "learn-automate": { + work: { + kind: "Seq", works: [ + makeCodingWork("scrum-learn-task"), + makeCodingWork("refine-task"), + makeRefWork("git-push") + ] + } + }, + }, + grid: [{ + keyword: "scrum", + jobs: [ + undefined, + { name: "problem", jobName: "scrum-problem" }, + { name: "update", jobName: "scrum-update" }, + { name: "review", jobName: "scrum-review" } + ] + }, { + keyword: "design w/ scrum", + jobs: [ + { name: "code directly", jobName: "design-next-automate" }, + { name: "problem next", jobName: "design-problem-next" }, + { name: "update", jobName: "design-update" }, + { name: "review", jobName: "design-review" } + ] + }, { + keyword: "design w/ task", + jobs: [ + { name: "code directly", jobName: "design-problem-automate" }, + { name: "problem", jobName: "design-problem" } + ] + }, { + keyword: "plan", + jobs: [ + { name: "code directly", jobName: "plan-automate" }, + { name: "problem", jobName: "plan-problem" }, + { name: "update", jobName: "plan-update" }, + { name: "review", jobName: "plan-review" } + ] + }, { + keyword: "summary", + jobs: [ + { name: "code directly", jobName: "summary-automate" }, + { name: "problem", jobName: "summary-problem" }, + { name: "update", jobName: "summary-update" }, + { name: "review", jobName: "summary-review" } + ] + }, { + keyword: "execute", + jobs: [ + { name: "code directly", jobName: "execute-automate" }, + { name: "start", jobName: "execute-start" }, + { name: "update", jobName: "execute-update" } + ] + }, { + keyword: "verify", + jobs: [ + undefined, + { name: "start", jobName: "verify-start" }, + { name: "update", jobName: "verify-update" } + ] + }, { + keyword: "evolution", + jobs: [ + { name: "evolution", jobName: "learn-automate" }, + { name: "scrum learn", jobName: "scrum-learn" }, + { name: "refine", jobName: "refine" } + ] + }] +} + +export const entry = validateEntry(entryInput, "jobsData.ts:"); diff --git a/.github/Agent/packages/CopilotPortal/src/jobsDef.ts b/.github/Agent/packages/CopilotPortal/src/jobsDef.ts new file mode 100644 index 00000000..173e9521 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/src/jobsDef.ts @@ -0,0 +1,481 @@ +// ---- Task ---- + +export type Model = + | { category: string; } + | { id: string; } + ; + +export type Prompt = string[]; + +export interface TaskRetry { + retryTimes: number; + additionalPrompt?: Prompt; +} + +export interface Task { + model?: Model; + prompt: Prompt; + requireUserInput: boolean; + availability?: { + condition?: Prompt; + }; + criteria?: { + toolExecuted?: string[]; + failureAction: TaskRetry; + } & ({ + condition: Prompt; + runConditionInSameSession: boolean; + } | never); +} + +// ---- Work ---- + +export interface TaskWork { + kind: "Ref"; + workIdInJob: T; + taskId: string; + modelOverride?: Model; +} + +export interface SequentialWork { + kind: "Seq"; + works: Work[]; +} + +export interface ParallelWork { + kind: "Par"; + works: Work[]; +} + +export interface LoopWork { + kind: "Loop"; + preCondition?: [boolean, Work]; + postCondition?: [boolean, Work]; + body: Work; +} + +export interface AltWork { + kind: "Alt"; + condition: Work; + trueWork?: Work; + falseWork?: Work; +} + +export type Work = TaskWork | SequentialWork | ParallelWork | LoopWork | AltWork; + +export function assignWorkId(work: Work): Work { + function helper(w: Work, nextId: number[]): Work { + switch (w.kind) { + case "Ref": { + return { ...w, workIdInJob: nextId[0]++ }; + } + case "Seq": { + return { ...w, works: w.works.map(work => helper(work, nextId)) }; + } + case "Par": { + return { ...w, works: w.works.map(work => helper(work, nextId)) }; + } + case "Loop": { + return { + ...w, + preCondition: w.preCondition ? [w.preCondition[0], helper(w.preCondition[1], nextId)] : undefined, + postCondition: w.postCondition ? [w.postCondition[0], helper(w.postCondition[1], nextId)] : undefined, + body: helper(w.body, nextId) + }; + } + case "Alt": { + return { + ...w, + condition: helper(w.condition, nextId), + trueWork: w.trueWork ? helper(w.trueWork, nextId) : undefined, + falseWork: w.falseWork ? helper(w.falseWork, nextId) : undefined + }; + } + } + } + return helper(work, [0]); +} + +// ---- Job ---- + +export interface Job { + requireUserInput?: boolean; + work: Work; +} + +export interface GridColumn { + name: string; + jobName: string; +} + +export interface GridRow { + keyword: string; + jobs: (GridColumn | undefined)[]; +} + +// ---- Entry ---- + +export interface ModelRetry { + modelId: string; + retries: number; +} + +export interface Entry { + models: { [key in string]: string }; + drivingSessionRetries: ModelRetry[]; + promptVariables: { [key in string]: string[] }; + tasks: { [key in string]: Task }; + jobs: { [key in string]: Job }; + grid: GridRow[]; +} + +// ---- Validation and Helpers ---- + +export const availableTools: string[] = [ + "job_prepare_document", + "job_boolean_true", + "job_boolean_false", + "job_prerequisite_failed" +]; + +export const runtimeVariables: string[] = [ + "$user-input", + "$task-model", + "$reported-document", + "$reported-true-reason", + "$reported-false-reason" +]; + +export function getModelId(model: Model, entry: Entry): string { + if ("category" in model) { + return entry.models[model.category]; + } else { + return model.id; + } +} + + +export const SESSION_CRASH_PREFIX = "The session crashed, please redo and here is the last request:\n"; +export const DEFAULT_CRITERIA_RETRIES = 5; + +export function retryWithNewSessionCondition(retryTimes: number = 3): TaskRetry { + return { retryTimes }; +} + +export function retryFailed(retryTimes: number = DEFAULT_CRITERIA_RETRIES): TaskRetry { + return { retryTimes, additionalPrompt: ["Please continue as you seemed to be accidentally stopped."] }; +} + +export function retryFailedCondition(retryTimes: number = DEFAULT_CRITERIA_RETRIES): TaskRetry { + return { retryTimes, additionalPrompt: ["Please continue as you seemed to be accidentally stopped, because I spotted that: $reported-false-reason"] }; +} + +export function expandPromptStatic(entry: Entry, codePath: string, prompt: Prompt, requiresBooleanTool?: boolean): Prompt { + if (prompt.length === 0) { + throw new Error(`${codePath}: Prompt is empty.`); + } + const joined = prompt.join("\n"); + const resolved = resolveVariablesStatic(entry, codePath, joined); + if (requiresBooleanTool) { + if (!resolved.includes("job_boolean_true") && !resolved.includes("job_boolean_false")) { + throw new Error(`${codePath}: Boolean tool (job_boolean_true or job_boolean_false) must be mentioned.`); + } + } + return [resolved]; +} + +function resolveVariablesStatic(entry: Entry, codePath: string, text: string): string { + return text.replace(/\$[a-zA-Z]+(?:-[a-zA-Z]+)*/g, (match) => { + const variableName = match; + if (runtimeVariables.includes(variableName)) { + return variableName; + } + const key = variableName.slice(1); // remove leading $ + if (key in entry.promptVariables) { + const childCodePath = `${codePath}/${variableName}`; + const childPrompt = entry.promptVariables[key]; + const expanded = expandPromptStatic(entry, childCodePath, childPrompt); + return expanded[0]; + } + throw new Error(`${codePath}: Cannot find prompt variable: ${variableName}.`); + }); +} + +export function expandPromptDynamic(entry: Entry, prompt: Prompt, values: Record): Prompt { + if (prompt.length !== 1) { + throw new Error(`expandPromptDynamic: Prompt must have exactly one item, got ${prompt.length}.`); + } + const text = prompt[0]; + const resolved = text.replace(/\$[a-zA-Z]+(?:-[a-zA-Z]+)*/g, (match) => { + const variableName = match; + const key = variableName.slice(1); + if (key in values) { + return values[key]; + } + return ""; + }); + return [resolved]; +} + +export function validateEntry(entry: Entry, codePath: string): Entry { + // Validate models.driving exists + if (!("driving" in entry.models)) { + throw new Error(`${codePath}entry.models.driving: Should exist.`); + } + + // Validate drivingSessionRetries contains at least one item and [0].modelId equals models.driving + if (!entry.drivingSessionRetries || entry.drivingSessionRetries.length === 0) { + throw new Error(`${codePath}entry.drivingSessionRetries: Should contain at least one item.`); + } + if (entry.drivingSessionRetries[0].modelId !== entry.models.driving) { + throw new Error(`${codePath}entry.drivingSessionRetries: The first modelId should equal to entry.models.driving.`); + } + + const modelKeys = Object.keys(entry.models).filter(k => k !== "reviewers"); + const gridKeywords = entry.grid.map(row => row.keyword); + const jobKeys = entry.jobs ? Object.keys(entry.jobs) : []; + + // Validate grid jobNames + for (let rowIndex = 0; rowIndex < entry.grid.length; rowIndex++) { + const row = entry.grid[rowIndex]; + for (let columnIndex = 0; columnIndex < row.jobs.length; columnIndex++) { + const col = row.jobs[columnIndex]; + if (col && !jobKeys.includes(col.jobName)) { + throw new Error(`${codePath}entry.grid[${rowIndex}].jobs[${columnIndex}].jobName: "${col.jobName}" is not a valid job name.`); + } + } + } + + for (const [taskName, task] of Object.entries(entry.tasks)) { + const taskBase = `${codePath}entry.tasks["${taskName}"]`; + + // Validate model + if (task?.model && "category" in task.model) { + if (!modelKeys.includes(task.model.category)) { + throw new Error(`${taskBase}.model.category: "${task.model.category}" is not a valid model key.`); + } + } + + // Expand and validate prompt + task.prompt = expandPromptStatic(entry, `${taskBase}.prompt`, task.prompt); + + // Validate requireUserInput + const expandedPromptText = task.prompt[0]; + if (task.requireUserInput) { + if (!expandedPromptText.includes("$user-input")) { + throw new Error(`${taskBase}.requireUserInput: Prompt should use $user-input when requireUserInput is true.`); + } + } else { + if (expandedPromptText.includes("$user-input")) { + throw new Error(`${taskBase}.requireUserInput: Prompt should not use $user-input when requireUserInput is false.`); + } + } + + // Validate availability + if (task.availability) { + if (task.availability.condition) { + task.availability.condition = expandPromptStatic(entry, `${taskBase}.availability.condition`, task.availability.condition, true); + } + } + + // Validate criteria + if (task.criteria) { + if (task.criteria.toolExecuted) { + for (let i = 0; i < task.criteria.toolExecuted.length; i++) { + const tool = task.criteria.toolExecuted[i]; + if (!availableTools.includes(tool)) { + throw new Error(`${taskBase}.criteria.toolExecuted[${i}]: "${tool}" is not an available tool.`); + } + } + } + if (task.criteria.condition) { + task.criteria.condition = expandPromptStatic(entry, `${taskBase}.criteria.condition`, task.criteria.condition, true); + } + if (task.criteria.failureAction.additionalPrompt) { + task.criteria.failureAction.additionalPrompt = expandPromptStatic(entry, `${taskBase}.criteria.failureAction.additionalPrompt`, task.criteria.failureAction.additionalPrompt); + } + } + } + + // Validate jobs + if (entry.jobs) { + const allModelKeys = Object.keys(entry.models); + for (const [jobName, job] of Object.entries(entry.jobs)) { + const jobBase = `${codePath}entry.jobs["${jobName}"]`; + + // Simplify work tree: flatten nested Seq/Par + job.work = simplifyWork(job.work); + + // Assign unique workIdInJob to each Ref node + job.work = assignWorkId(job.work as unknown as Work); + + validateWork(entry, job.work, jobBase + ".work", allModelKeys); + + // Compute requireUserInput for the job + const jobRequiresInput = collectTaskIdsFromWork(job.work).some( + taskId => entry.tasks[taskId]?.requireUserInput + ); + if (job.requireUserInput !== undefined && job.requireUserInput !== jobRequiresInput) { + throw new Error(`${jobBase}.requireUserInput: Should be ${jobRequiresInput} but is ${job.requireUserInput}.`); + } + job.requireUserInput = jobRequiresInput; + } + } + + return entry; +} + +// ---- Helper: ensure cross references in Entry correct ---- + +function validateWork(entry: Entry, work: Work, codePath: string, modelKeys: string[]): void { + switch (work.kind) { + case "Ref": { + const tw = work as TaskWork; + if (!(tw.taskId in entry.tasks)) { + throw new Error(`${codePath}.taskId: "${tw.taskId}" is not a valid task name.`); + } + if (tw.modelOverride) { + if ("category" in tw.modelOverride) { + if (!modelKeys.includes(tw.modelOverride.category)) { + throw new Error(`${codePath}.modelOverride.category: "${tw.modelOverride.category}" is not a valid model key.`); + } + } + } else { + // modelOverride must be defined if the task has no specified model + const task = entry.tasks[tw.taskId]; + if (!task.model) { + throw new Error(`${codePath}.modelOverride: must be defined because task "${tw.taskId}" has no specified model.`); + } + } + break; + } + case "Seq": { + const sw = work as SequentialWork; + if (sw.works.length === 0) { + throw new Error(`${codePath}.works: should have at least one element.`); + } + for (let i = 0; i < sw.works.length; i++) { + validateWork(entry, sw.works[i], `${codePath}.works[${i}]`, modelKeys); + } + break; + } + case "Par": { + const pw = work as ParallelWork; + if (pw.works.length === 0) { + throw new Error(`${codePath}.works: should have at least one element.`); + } + for (let i = 0; i < pw.works.length; i++) { + validateWork(entry, pw.works[i], `${codePath}.works[${i}]`, modelKeys); + } + break; + } + case "Loop": { + const lw = work as LoopWork; + if (lw.preCondition) { + validateWork(entry, lw.preCondition[1], `${codePath}.preCondition[1]`, modelKeys); + } + if (lw.postCondition) { + validateWork(entry, lw.postCondition[1], `${codePath}.postCondition[1]`, modelKeys); + } + validateWork(entry, lw.body, `${codePath}.body`, modelKeys); + break; + } + case "Alt": { + const aw = work as AltWork; + validateWork(entry, aw.condition, `${codePath}.condition`, modelKeys); + if (aw.trueWork) { + validateWork(entry, aw.trueWork, `${codePath}.trueWork`, modelKeys); + } + if (aw.falseWork) { + validateWork(entry, aw.falseWork, `${codePath}.falseWork`, modelKeys); + } + break; + } + } +} + +// ---- Helper: collect all TaskWork taskIds from a work tree ---- + +function collectTaskIdsFromWork(work: Work): string[] { + const ids: string[] = []; + function collect(w: Work): void { + switch (w.kind) { + case "Ref": + ids.push((w as TaskWork).taskId); + break; + case "Seq": + case "Par": + for (const child of (w as SequentialWork | ParallelWork).works) { + collect(child); + } + break; + case "Loop": { + const lw = w as LoopWork; + if (lw.preCondition) collect(lw.preCondition[1]); + collect(lw.body); + if (lw.postCondition) collect(lw.postCondition[1]); + break; + } + case "Alt": { + const aw = w as AltWork; + collect(aw.condition); + if (aw.trueWork) collect(aw.trueWork); + if (aw.falseWork) collect(aw.falseWork); + break; + } + } + } + collect(work); + return ids; +} + +// ---- Helper: simplify work tree (flatten nested Seq/Par) ---- + +function simplifyWork(work: Work): Work { + switch (work.kind) { + case "Ref": + return work; + case "Seq": { + const flatWorks: Work[] = []; + for (const child of (work as SequentialWork).works) { + const simplified = simplifyWork(child); + if (simplified.kind === "Seq") { + flatWorks.push(...(simplified as SequentialWork).works); + } else { + flatWorks.push(simplified); + } + } + return { ...work, works: flatWorks } as SequentialWork; + } + case "Par": { + const flatWorks: Work[] = []; + for (const child of (work as ParallelWork).works) { + const simplified = simplifyWork(child); + if (simplified.kind === "Par") { + flatWorks.push(...(simplified as ParallelWork).works); + } else { + flatWorks.push(simplified); + } + } + return { ...work, works: flatWorks } as ParallelWork; + } + case "Loop": { + const lw = work as LoopWork; + return { + ...lw, + preCondition: lw.preCondition ? [lw.preCondition[0], simplifyWork(lw.preCondition[1])] : undefined, + body: simplifyWork(lw.body), + postCondition: lw.postCondition ? [lw.postCondition[0], simplifyWork(lw.postCondition[1])] : undefined, + } as LoopWork; + } + case "Alt": { + const aw = work as AltWork; + return { + ...aw, + condition: simplifyWork(aw.condition), + trueWork: aw.trueWork ? simplifyWork(aw.trueWork) : undefined, + falseWork: aw.falseWork ? simplifyWork(aw.falseWork) : undefined, + } as AltWork; + } + } +} diff --git a/.github/Agent/packages/CopilotPortal/src/sharedApi.ts b/.github/Agent/packages/CopilotPortal/src/sharedApi.ts new file mode 100644 index 00000000..24b7843d --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/src/sharedApi.ts @@ -0,0 +1,313 @@ +import * as http from "node:http"; +import { CopilotClient } from "@github/copilot-sdk"; + +// ---- Helpers ---- + +export function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + req.on("error", reject); + }); +} + +export function jsonResponse(res: http.ServerResponse, statusCode: number, data: unknown): void { + res.writeHead(statusCode, { "Content-Type": "application/json" }); + res.end(JSON.stringify(data)); +} + +// ---- Test Mode ---- + +let _testMode = false; +export function setTestMode(value: boolean): void { _testMode = value; } +export function isTestMode(): boolean { return _testMode; } +export function getCountDownMs(): number { return _testMode ? 5000 : 60000; } + +// ---- Token-based Live State ---- + +export interface LiveResponse { + [key: string]: unknown; +} + +export interface TokenState { + position: number; + lastResponseTime: number | undefined; + pendingResolve: ((response: LiveResponse) => void) | null; + pendingClosedError: string; + httpTimeout: ReturnType | null; + batchTimeout: ReturnType | null; +} + +export interface LiveEntityState { + responses: LiveResponse[]; + tokens: Map; + closed: boolean; + countDownBegin: number | undefined; // undefined while entity is running + countDownMs: number; + onDelete?: () => void; +} + +export function createLiveEntityState(countDownMs: number, onDelete?: () => void): LiveEntityState { + return { + responses: [], + tokens: new Map(), + closed: false, + countDownBegin: undefined, + countDownMs, + onDelete, + }; +} + +function tryCleanupEntity(entity: LiveEntityState, token: string): void { + entity.tokens.delete(token); + if (entity.tokens.size === 0 && entity.onDelete) { + entity.onDelete(); + } +} + +// Shared helper: drain all available responses, update position/lastResponseTime, and resolve. +// Used by pushLiveResponse (immediate or via batchTimeout), httpTimeout, and closeLiveEntity. +function resolveToken(entity: LiveEntityState, token: string, tokenState: TokenState): void { + if (!tokenState.pendingResolve) return; + + if (tokenState.httpTimeout) { clearTimeout(tokenState.httpTimeout); tokenState.httpTimeout = null; } + if (tokenState.batchTimeout) { clearTimeout(tokenState.batchTimeout); tokenState.batchTimeout = null; } + + const resolve = tokenState.pendingResolve; + tokenState.pendingResolve = null; + + if (tokenState.position < entity.responses.length) { + const responses = entity.responses.slice(tokenState.position); + tokenState.position = entity.responses.length; + tokenState.lastResponseTime = Date.now(); + resolve({ responses }); + } else if (entity.closed) { + tryCleanupEntity(entity, token); + resolve({ error: tokenState.pendingClosedError }); + } else { + resolve({ error: "HttpRequestTimeout" }); + } +} + +function removeResponseAtIndex(entity: LiveEntityState, idx: number): void { + entity.responses.splice(idx, 1); + for (const [, ts] of entity.tokens) { + if (ts.position > idx) { + ts.position--; + } + } +} + +function mergeDeltas(entity: LiveEntityState, indices: number[]): void { + if (indices.length <= 1) return; + const sorted = [...indices].sort((a, b) => a - b); + const mergedDelta = sorted.map(i => entity.responses[i].delta as string).join(""); + entity.responses[sorted[0]] = { ...entity.responses[sorted[0]], delta: mergedDelta }; + // Remove from end to beginning, keeping the first (lowest index) + for (let j = sorted.length - 1; j >= 1; j--) { + removeResponseAtIndex(entity, sorted[j]); + } +} + +function optimizeOnEnd(entity: LiveEntityState, response: LiveResponse): void { + const isReasoning = response.callback === "onEndReasoning"; + const deltaCallback = isReasoning ? "onReasoning" : "onMessage"; + const startCallback = isReasoning ? "onStartReasoning" : "onStartMessage"; + const idKey = isReasoning ? "reasoningId" : "messageId"; + const id = response[idKey] as string; + + // Search from the end, stop at onStart, remove all deltas for this id + for (let i = entity.responses.length - 1; i >= 0; i--) { + const r = entity.responses[i]; + if (r[idKey] === id && r.callback === startCallback) break; + if (r[idKey] === id && r.callback === deltaCallback) { + removeResponseAtIndex(entity, i); + } + } +} + +function optimizeOnDelta(entity: LiveEntityState, response: LiveResponse): void { + const isReasoning = response.callback === "onReasoning"; + const startCallback = isReasoning ? "onStartReasoning" : "onStartMessage"; + const deltaCallback = response.callback as string; + const idKey = isReasoning ? "reasoningId" : "messageId"; + const id = response[idKey] as string; + + // Find all delta entries for this id, from end, stopping at start + const deltaIndices: number[] = []; + for (let i = entity.responses.length - 1; i >= 0; i--) { + const r = entity.responses[i]; + if (r[idKey] === id && r.callback === startCallback) break; + if (r[idKey] === id && r.callback === deltaCallback) { + deltaIndices.push(i); + } + } + + if (deltaIndices.length <= 1) return; + + if (entity.tokens.size === 0) { + // No readers — merge all deltas + mergeDeltas(entity, deltaIndices); + return; + } + + let largestPos = -Infinity; + let smallestPos = Infinity; + for (const [, ts] of entity.tokens) { + if (ts.position > largestPos) largestPos = ts.position; + if (ts.position < smallestPos) smallestPos = ts.position; + } + + const unread = deltaIndices.filter(i => i >= largestPos); + const allRead = deltaIndices.filter(i => i < smallestPos); + + // Merge unread first (higher indices), then allRead (lower indices) + mergeDeltas(entity, unread); + mergeDeltas(entity, allRead); +} + +export function pushLiveResponse(entity: LiveEntityState, response: LiveResponse): void { + // Optimization: when onEndReasoning/onEndMessage, remove matching deltas (scoped to current block) + if (response.callback === "onEndReasoning" || response.callback === "onEndMessage") { + optimizeOnEnd(entity, response); + } + + entity.responses.push(response); + + // Optimization: when onReasoning/onMessage, merge deltas + if (response.callback === "onReasoning" || response.callback === "onMessage") { + optimizeOnDelta(entity, response); + } + + // Notify pending tokens that have unread data and no batchTimeout already scheduled + for (const [tok, ts] of entity.tokens) { + if (ts.pendingResolve && ts.position < entity.responses.length && !ts.batchTimeout) { + const now = Date.now(); + if (ts.lastResponseTime === undefined || now - ts.lastResponseTime >= 5000) { + // No recent response or window elapsed — resolve immediately + resolveToken(entity, tok, ts); + } else { + // Schedule delayed batch to accumulate more responses + const remaining = 5000 - (now - ts.lastResponseTime); + const ent = entity; + const token = tok; + const tokenState = ts; + ts.batchTimeout = setTimeout(() => { + tokenState.batchTimeout = null; + resolveToken(ent, token, tokenState); + }, remaining); + } + } + } +} + +export function closeLiveEntity(entity: LiveEntityState): void { + entity.closed = true; + entity.countDownBegin = Date.now(); + // Wake up all tokens that are waiting and fully drained + for (const [token, tokenState] of entity.tokens) { + if (tokenState.pendingResolve && tokenState.position >= entity.responses.length) { + resolveToken(entity, token, tokenState); + } + } +} + +export function waitForLiveResponse( + entity: LiveEntityState | undefined, + token: string, + timeoutMs: number, + notFoundError: string, + closedError: string, +): Promise { + // Entity doesn't exist + if (!entity) { + return Promise.resolve({ error: notFoundError }); + } + + // Check if token is known + let tokenState = entity.tokens.get(token); + if (!tokenState) { + // New token — check lifecycle + if (entity.countDownBegin !== undefined) { + if (Date.now() - entity.countDownBegin > entity.countDownMs) { + return Promise.resolve({ error: notFoundError }); + } + } + tokenState = { position: 0, lastResponseTime: undefined, pendingResolve: null, pendingClosedError: "", httpTimeout: null, batchTimeout: null }; + entity.tokens.set(token, tokenState); + } + + // Check parallel call on same (entity, token) + if (tokenState.pendingResolve) { + return Promise.resolve({ error: "ParallelCallNotSupported" }); + } + + // Batch drain: return ALL available responses from current position + if (tokenState.position < entity.responses.length) { + const responses = entity.responses.slice(tokenState.position); + tokenState.position = entity.responses.length; + tokenState.lastResponseTime = Date.now(); + return Promise.resolve({ responses }); + } + + // Closed and fully drained? + if (entity.closed && tokenState.position >= entity.responses.length) { + tryCleanupEntity(entity, token); + return Promise.resolve({ error: closedError }); + } + + // Wait for next response or timeout + const ts = tokenState; + const ent = entity; + const tok = token; + return new Promise((resolve) => { + ts.pendingResolve = resolve; + ts.pendingClosedError = closedError; + ts.httpTimeout = setTimeout(() => { + ts.httpTimeout = null; + resolveToken(ent, tok, ts); + }, timeoutMs); + }); +} + +export function shutdownLiveEntity(entity: LiveEntityState): void { + // Resolve all pending token waits and clear + for (const [, tokenState] of entity.tokens) { + if (tokenState.httpTimeout) { clearTimeout(tokenState.httpTimeout); tokenState.httpTimeout = null; } + if (tokenState.batchTimeout) { clearTimeout(tokenState.batchTimeout); tokenState.batchTimeout = null; } + if (tokenState.pendingResolve) { + const resolve = tokenState.pendingResolve; + tokenState.pendingResolve = null; + resolve({ error: "HttpRequestTimeout" }); + } + } + entity.tokens.clear(); +} + +// ---- Copilot Client ---- + +let copilotClient: CopilotClient | null = null; +let copilotClientPromise: Promise | null = null; + +export async function ensureCopilotClient(): Promise { + if (copilotClient) return copilotClient; + if (!copilotClientPromise) { + copilotClientPromise = (async () => { + const client = new CopilotClient(); + await client.start(); + copilotClient = client; + copilotClientPromise = null; + return client; + })(); + } + return copilotClientPromise; +} + +export function stopCoplilotClient(): void { + copilotClientPromise = null; + if (copilotClient) { + copilotClient.stop(); + copilotClient = null; + } +} diff --git a/.github/Agent/packages/CopilotPortal/src/taskApi.ts b/.github/Agent/packages/CopilotPortal/src/taskApi.ts new file mode 100644 index 00000000..0334cac8 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/src/taskApi.ts @@ -0,0 +1,904 @@ +import * as http from "node:http"; +import type { ICopilotSession } from "./copilotSession.js"; +import type { Entry, Task, Prompt } from "./jobsDef.js"; +import { expandPromptDynamic, getModelId, SESSION_CRASH_PREFIX } from "./jobsDef.js"; +import { + helperSessionStart, + helperSessionStop, + helperPushSessionResponse, + helperGetSession, + jsonResponse, +} from "./copilotApi.js"; +import { + readBody, + getCountDownMs, + createLiveEntityState, + pushLiveResponse, + closeLiveEntity, + waitForLiveResponse, + shutdownLiveEntity, + type LiveEntityState, + type LiveResponse, +} from "./sharedApi.js"; + +// ---- Error Formatting Helper ---- + +const MAX_CRASH_RETRIES = 5; + +export function errorToDetailedString(err: unknown): string { + if (err instanceof Error) { + const info: Record = {}; + if (err.name !== undefined) info.name = err.name; + if (err.message !== undefined) info.message = err.message; + if (err.stack !== undefined) info.stack = err.stack; + if ((err as any).cause !== undefined) { + info.cause = errorToDetailedString((err as any).cause); + } + return JSON.stringify(info); + } + if (typeof err === "string") return err; + try { return JSON.stringify(err); } catch { return String(err); } +} + +// ---- Task Stopped Error ---- + +export class TaskStoppedError extends Error { + constructor() { + super("Task was stopped"); + this.name = "TaskStoppedError"; + } +} + +// ---- Types ---- + +export interface ICopilotTask { + readonly drivingSession: ICopilotSession; + readonly status: "Executing" | "Succeeded" | "Failed"; + readonly crashError?: any; + stop(): void; +} + +export interface ICopilotTaskCallback { + taskSucceeded(): void; + taskFailed(): void; + taskDecision(reason: string): void; + // Unavailable in borrowing session mode + taskSessionStarted(taskSession: ICopilotSession, taskId: string, isDrivingSession: boolean): void; + // Unavailable in borrowing session mode + taskSessionStopped(taskSession: ICopilotSession, taskId: string, succeeded: boolean): void; +} + +// ---- Runtime Variable Helpers ---- + +function expandPrompt(entry: Entry, prompt: Prompt, runtimeValues: Record): string { + const result = expandPromptDynamic(entry, prompt, runtimeValues); + return result[0]; +} + +// ---- Tool Monitoring ---- + +interface ToolMonitor { + toolsCalled: Set; + booleanResult: boolean | null; + cleanup: () => void; +} + +function monitorSessionTools(session: ICopilotSession, runtimeValues: Record): ToolMonitor { + const toolsCalled = new Set(); + let booleanResult: boolean | null = null; + let active = true; + + const raw = session.rawSection; + + const onToolStart = (event: { data: { toolName: string; arguments?: unknown } }) => { + if (!active) return; + const toolName = event.data.toolName; + toolsCalled.add(toolName); + + // Extract argument as string + let argStr = ""; + if (event.data.arguments) { + if (typeof event.data.arguments === "string") { + argStr = event.data.arguments; + } else if (typeof event.data.arguments === "object") { + const values = Object.values(event.data.arguments as Record); + const strVal = values.find(v => typeof v === "string"); + argStr = strVal ? String(strVal) : JSON.stringify(event.data.arguments); + } + } + + if (toolName === "job_prepare_document") { + // Only keep first line and trim spaces + const lines = argStr.split("\n"); + runtimeValues["reported-document"] = lines[0].trim(); + } else if (toolName === "job_boolean_true") { + runtimeValues["reported-true-reason"] = argStr; + delete runtimeValues["reported-false-reason"]; + booleanResult = true; + } else if (toolName === "job_boolean_false") { + runtimeValues["reported-false-reason"] = argStr; + delete runtimeValues["reported-true-reason"]; + booleanResult = false; + } + }; + + raw.on("tool.execution_start", onToolStart); + + return { + toolsCalled, + get booleanResult() { return booleanResult; }, + set booleanResult(v: boolean | null) { booleanResult = v; }, + cleanup() { + active = false; + }, + } as ToolMonitor; +} + +// ---- startTask ---- + +class CopilotTaskImpl implements ICopilotTask { + + private readonly entry: Entry; + private readonly task: Task; + private readonly criteria?: Task["criteria"]; + private readonly borrowingMode: boolean; + private readonly singleModel: boolean; + private taskModelId?: string; + private runtimeValues: Record = {}; + private stopped = false; + private readonly activeSessions = new Map(); // sessionId -> session + private primaryDrivingSession?: ICopilotSession; + + public status: "Executing" | "Succeeded" | "Failed" = "Executing"; + public crashError?: any; + + public get drivingSession(): ICopilotSession { + return this.primaryDrivingSession || this.assignedDrivingSession!; + } + + public stop() { + if (this.stopped) return; + this.stopped = true; + this.status = "Failed"; + for (const [, session] of this.activeSessions) { + helperSessionStop(session).catch(() => { }); + } + this.activeSessions.clear(); + } + + // Helper that wraps session.sendRequest with stopped guards. + // When this.stopped, throws TaskStoppedError so retrying won't issue. + private async guardedSendRequest(session: ICopilotSession, message: string, timeout?: number): Promise { + if (this.stopped) throw new TaskStoppedError(); + await session.sendRequest(message, timeout); + if (this.stopped) throw new TaskStoppedError(); + } + + constructor( + entry: Entry, + taskName: string, + userInput: string, + private assignedDrivingSession: ICopilotSession | undefined, + private ignorePrerequisiteCheck: boolean, + private callback: ICopilotTaskCallback, + taskModelIdOverride?: string, + private workingDirectory?: string) { + this.entry = entry; + this.task = this.entry.tasks[taskName]; + if (!this.task) { + throw new Error(`Task "${taskName}" not found.`); + } + + this.criteria = this.task.criteria; + this.borrowingMode = assignedDrivingSession !== undefined; + this.singleModel = this.borrowingMode || + !this.criteria || + !("runConditionInSameSession" in this.criteria) || + (this.criteria as any).runConditionInSameSession === undefined || + (this.criteria as any).runConditionInSameSession === true; + + // Determine task model ID + this.taskModelId = taskModelIdOverride; + if (!this.taskModelId && this.task.model) { + this.taskModelId = getModelId(this.task.model, this.entry); + } + if (!this.taskModelId && !this.borrowingMode && this.singleModel) { + this.taskModelId = this.entry.models.driving; + } + if (!this.taskModelId && !this.borrowingMode && !this.singleModel) { + this.taskModelId = this.entry.models.driving; + } + + if (userInput) this.runtimeValues["user-input"] = userInput; + this.primaryDrivingSession = assignedDrivingSession; + } + + // ---- Session management helpers ---- + + private async openSession(modelId: string, isDriving: boolean): Promise<[ICopilotSession, string]> { + const [session, sessionId] = await helperSessionStart(modelId, this.workingDirectory); + this.activeSessions.set(sessionId, session); + this.callback.taskSessionStarted(session, sessionId, isDriving); + const sessionType = this.singleModel ? "task" : (isDriving ? "driving" : "task"); + this.callback.taskDecision(`[SESSION STARTED] ${sessionType} session started with model ${modelId}`); + if (isDriving && !this.primaryDrivingSession) this.primaryDrivingSession = session; + return [session, sessionId]; + } + + private async closeExistingSession(session: ICopilotSession, sessionId: string, succeeded: boolean): Promise { + this.activeSessions.delete(sessionId); + await helperSessionStop(session).catch(() => { }); + this.callback.taskSessionStopped(session, sessionId, succeeded); + } + + // ---- Send prompt with monitoring and crash retry ---- + + private async sendMonitoredPrompt( + sessionRef: { session: ICopilotSession; id: string }, + prompt: string, + modelId: string, + isDriving: boolean, + ): Promise<{ toolsCalled: Set; booleanResult: boolean | null }> { + if (this.borrowingMode) { + // Borrowing mode: no retry + return this.sendMonitoredPromptOnce(sessionRef, prompt); + } + + if (isDriving) { + return this.sendMonitoredPromptDriving(sessionRef, prompt, modelId); + } else { + return this.sendMonitoredPromptTask(sessionRef, prompt, modelId); + } + } + + private async sendMonitoredPromptOnce( + sessionRef: { session: ICopilotSession; id: string }, + prompt: string, + ): Promise<{ toolsCalled: Set; booleanResult: boolean | null }> { + const monitor = monitorSessionTools(sessionRef.session, this.runtimeValues); + try { + helperPushSessionResponse(sessionRef.session, { callback: "onGeneratedUserPrompt", prompt }); + await this.guardedSendRequest(sessionRef.session, prompt); + monitor.cleanup(); + return { toolsCalled: monitor.toolsCalled, booleanResult: monitor.booleanResult }; + } catch (err) { + monitor.cleanup(); + if (err instanceof TaskStoppedError) throw err; + this.callback.taskDecision(`[SESSION CRASHED] ${errorToDetailedString(err)}`); + throw err; + } + } + + private async sendMonitoredPromptTask( + sessionRef: { session: ICopilotSession; id: string }, + prompt: string, + modelId: string, + ): Promise<{ toolsCalled: Set; booleanResult: boolean | null }> { + const maxAttempts = MAX_CRASH_RETRIES; + let lastError: unknown; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const actualPrompt = attempt === 0 ? prompt : SESSION_CRASH_PREFIX + prompt; + const monitor = monitorSessionTools(sessionRef.session, this.runtimeValues); + + try { + helperPushSessionResponse(sessionRef.session, { callback: "onGeneratedUserPrompt", prompt: actualPrompt }); + await this.guardedSendRequest(sessionRef.session, actualPrompt); + monitor.cleanup(); + return { toolsCalled: monitor.toolsCalled, booleanResult: monitor.booleanResult }; + } catch (err) { + monitor.cleanup(); + if (err instanceof TaskStoppedError) throw err; + lastError = err; + this.callback.taskDecision(`[SESSION CRASHED] ${errorToDetailedString(err)}`); + + if (attempt < maxAttempts - 1) { + try { + await this.closeExistingSession(sessionRef.session, sessionRef.id, false); + const [newSession, newId] = await this.openSession(modelId, false); + sessionRef.session = newSession; + sessionRef.id = newId; + } catch (sessionErr) { + this.callback.taskDecision(`[SESSION CRASHED] Failed to create replacement session: ${errorToDetailedString(sessionErr)}`); + throw sessionErr; + } + } + } + } + + throw lastError; + } + + private async sendMonitoredPromptDriving( + sessionRef: { session: ICopilotSession; id: string }, + prompt: string, + modelId: string, + ): Promise<{ toolsCalled: Set; booleanResult: boolean | null }> { + // Refresh retry budget for each driving mission + const retryBudget = this.entry.drivingSessionRetries.map(r => r.retries); + let lastError: unknown; + + // First attempt with the original model + { + const monitor = monitorSessionTools(sessionRef.session, this.runtimeValues); + try { + helperPushSessionResponse(sessionRef.session, { callback: "onGeneratedUserPrompt", prompt }); + await this.guardedSendRequest(sessionRef.session, prompt); + monitor.cleanup(); + return { toolsCalled: monitor.toolsCalled, booleanResult: monitor.booleanResult }; + } catch (err) { + monitor.cleanup(); + if (err instanceof TaskStoppedError) throw err; + if (this.stopped) throw new TaskStoppedError(); + lastError = err; + this.callback.taskDecision(`[SESSION CRASHED] ${errorToDetailedString(err)}`); + } + } + + // Retry loop using drivingSessionRetries budget + while (retryBudget.some(b => b > 0)) { + for (let index = 0; index < this.entry.drivingSessionRetries.length; index++) { + if (retryBudget[index] <= 0) continue; + retryBudget[index]--; + + const retryModelId = this.entry.drivingSessionRetries[index].modelId; + const actualPrompt = SESSION_CRASH_PREFIX + prompt; + + try { + await this.closeExistingSession(sessionRef.session, sessionRef.id, false); + const [newSession, newId] = await this.openSession(retryModelId, true); + sessionRef.session = newSession; + sessionRef.id = newId; + } catch (sessionErr) { + this.callback.taskDecision(`[SESSION CRASHED] Failed to create replacement session: ${errorToDetailedString(sessionErr)}`); + throw sessionErr; + } + + const monitor = monitorSessionTools(sessionRef.session, this.runtimeValues); + try { + helperPushSessionResponse(sessionRef.session, { callback: "onGeneratedUserPrompt", prompt: actualPrompt }); + await this.guardedSendRequest(sessionRef.session, actualPrompt); + monitor.cleanup(); + return { toolsCalled: monitor.toolsCalled, booleanResult: monitor.booleanResult }; + } catch (err) { + monitor.cleanup(); + if (err instanceof TaskStoppedError) throw err; + if (this.stopped) throw new TaskStoppedError(); + lastError = err; + this.callback.taskDecision(`[SESSION CRASHED] ${errorToDetailedString(err)}`); + } + } + } + + throw lastError; + } + + // ---- Criteria checking ---- + + private async checkCriteriaResult( + toolsCalled: Set, + drivingRef: { session: ICopilotSession; id: string } | undefined, + drivingModelId: string, + ): Promise<{ passed: boolean; missingTools: string[] }> { + if (!this.criteria) return { passed: true, missingTools: [] }; + + // Check toolExecuted + let missingTools: string[] = []; + if (this.criteria.toolExecuted) { + for (const tool of this.criteria.toolExecuted) { + if (!toolsCalled.has(tool)) { + missingTools.push(tool); + } + } + if (missingTools.length > 0) { + this.callback.taskDecision(`[CRITERIA] toolExecuted check failed: missing tools: ${missingTools.join(", ")}`); + if (!("condition" in this.criteria) || !(this.criteria as any).condition) { + return { passed: false, missingTools }; + } + } + } + + // Check condition + if ("condition" in this.criteria && (this.criteria as any).condition) { + let condRef: { session: ICopilotSession; id: string }; + let needsClose = false; + + if (drivingRef) { + // Single model or borrowing: use existing session + condRef = drivingRef; + } else { + // Multiple models: create new driving session for condition check + const [ds, dsId] = await this.openSession(drivingModelId, true); + condRef = { session: ds, id: dsId }; + needsClose = true; + } + + const condPrompt = expandPrompt(this.entry, this.criteria.condition, this.runtimeValues); + let condPassed: boolean; + try { + const result = await this.sendMonitoredPrompt(condRef, condPrompt, drivingModelId, true); + condPassed = result.booleanResult === true; + } catch (err) { + if (needsClose) await this.closeExistingSession(condRef.session, condRef.id, false); + throw err; + } + + if (needsClose) await this.closeExistingSession(condRef.session, condRef.id, condPassed); + + if (condPassed) { + this.callback.taskDecision("[CRITERIA] Criteria condition passed"); + } else { + this.callback.taskDecision(`[CRITERIA] Criteria condition failed. Condition: ${JSON.stringify((this.criteria as any).condition)}`); + return { passed: false, missingTools }; + } + } + + // If toolsOk was false but condition passed, tools still failed + if (missingTools.length > 0) { + return { passed: false, missingTools }; + } + + return { passed: true, missingTools: [] }; + } + + private buildRetryPrompt(missingTools: string[]): string { + let prompt = expandPrompt(this.entry, this.task.prompt, this.runtimeValues); + if (missingTools.length > 0) { + prompt += `\n## Required Tool Not Called: ${missingTools.join(", ")}`; + } + if (this.criteria?.failureAction?.additionalPrompt) { + const expandedAdditional = expandPrompt(this.entry, this.criteria.failureAction.additionalPrompt, this.runtimeValues); + prompt += `\n## You accidentally Stopped\n${expandedAdditional}`; + } + return prompt; + } + + // ---- Execution ---- + + public async executeBorrowing(): Promise { + // === BORROWING SESSION MODE === + this.runtimeValues["task-model"] = this.taskModelId || ""; + + // Execute prompt directly on the given session + const promptText = expandPrompt(this.entry, this.task.prompt, this.runtimeValues); + const borrowedSession = this.drivingSession!; + + const monitor = monitorSessionTools(borrowedSession, this.runtimeValues); + try { + helperPushSessionResponse(borrowedSession, { callback: "onGeneratedUserPrompt", prompt: promptText }); + await this.guardedSendRequest(borrowedSession, promptText); + } catch (err) { + monitor.cleanup(); + if (err instanceof TaskStoppedError) throw err; + this.callback.taskDecision(`[SESSION CRASHED] Task crashed in borrowing session mode: ${errorToDetailedString(err)}`); + throw err; + } + monitor.cleanup(); + + // Check criteria using the borrowed session + const borrowedRef = { session: borrowedSession, id: "" }; + let { passed, missingTools } = this.criteria + ? await this.checkCriteriaResult(monitor.toolsCalled, borrowedRef, "") + : { passed: true, missingTools: [] as string[] }; + + // Retry loop (borrowing mode: same session, crash = immediate fail) + if (!passed && this.criteria?.failureAction) { + const maxRetries = this.criteria.failureAction.retryTimes; + for (let i = 0; i < maxRetries && !passed; i++) { + this.callback.taskDecision(`[OPERATION] Starting retry #${i + 1}`); + + const retryPrompt = this.buildRetryPrompt(missingTools); + const retryMonitor = monitorSessionTools(borrowedSession, this.runtimeValues); + try { + helperPushSessionResponse(borrowedSession, { callback: "onGeneratedUserPrompt", prompt: retryPrompt }); + await this.guardedSendRequest(borrowedSession, retryPrompt); + } catch (err) { + retryMonitor.cleanup(); + if (err instanceof TaskStoppedError) throw err; + this.callback.taskDecision(`[SESSION CRASHED] Crash during retry in borrowing mode: ${errorToDetailedString(err)}`); + throw err; + } + retryMonitor.cleanup(); + + const retResult = await this.checkCriteriaResult(retryMonitor.toolsCalled, borrowedRef, ""); + passed = retResult.passed; + missingTools = retResult.missingTools; + + this.callback.taskDecision(passed + ? `[CRITERIA] Criteria passed on retry #${i + 1}` + : `[CRITERIA] Criteria failed on retry #${i + 1}`); + } + if (!passed) { + this.callback.taskDecision(`[DECISION] Retry budget drained after ${maxRetries} retries`); + } + } + + // Report result + if (passed) { + this.status = "Succeeded"; + this.callback.taskDecision("[TASK SUCCEEDED] Decision: task succeeded"); + this.callback.taskSucceeded(); + } else { + this.status = "Failed"; + this.callback.taskDecision("[TASK FAILED] Decision: task failed (criteria not satisfied)"); + this.callback.taskFailed(); + } + } + + public async executeSingle(): Promise { + // === MANAGED SESSION MODE (SINGLE MODEL) === + const modelId = this.taskModelId || this.entry.models.driving; + this.runtimeValues["task-model"] = modelId; + + const [session, sessionId] = await this.openSession(modelId, true); + const ref = { session, id: sessionId }; + + // Check availability + if (this.task.availability && !this.ignorePrerequisiteCheck) { + if (this.task.availability.condition) { + const condPrompt = expandPrompt(this.entry, this.task.availability.condition, this.runtimeValues); + const result = await this.sendMonitoredPrompt(ref, condPrompt, modelId, true); + if (result.booleanResult !== true) { + this.callback.taskDecision(`[AVAILABILITY] Availability check failed: condition not satisfied. Condition: ${JSON.stringify(this.task.availability.condition)}`); + this.status = "Failed"; + await this.closeExistingSession(ref.session, ref.id, false); + this.callback.taskFailed(); + return; + } + this.callback.taskDecision("[AVAILABILITY] Availability check passed"); + } + } + + // Execute prompt + const promptText = expandPrompt(this.entry, this.task.prompt, this.runtimeValues); + const { toolsCalled } = await this.sendMonitoredPrompt(ref, promptText, modelId, true); + + // Check criteria + let { passed, missingTools } = this.task.criteria + ? await this.checkCriteriaResult(toolsCalled, ref, modelId) + : { passed: true, missingTools: [] as string[] }; + + // Retry loop + if (!passed && this.task.criteria?.failureAction) { + const maxRetries = this.task.criteria.failureAction.retryTimes; + for (let i = 0; i < maxRetries && !passed; i++) { + this.callback.taskDecision(`[OPERATION] Starting retry #${i + 1}`); + + const retryPrompt = this.buildRetryPrompt(missingTools); + const retryResult = await this.sendMonitoredPrompt(ref, retryPrompt, modelId, true); + + const checkResult = await this.checkCriteriaResult(retryResult.toolsCalled, ref, modelId); + passed = checkResult.passed; + missingTools = checkResult.missingTools; + + this.callback.taskDecision(passed + ? `[CRITERIA] Criteria passed on retry #${i + 1}` + : `[CRITERIA] Criteria failed on retry #${i + 1}`); + } + if (!passed) { + this.callback.taskDecision(`[DECISION] Retry budget drained after ${maxRetries} retries`); + } + } + + // Report result + if (passed) { + this.status = "Succeeded"; + this.callback.taskDecision("[TASK SUCCEEDED] Decision: task succeeded"); + await this.closeExistingSession(ref.session, ref.id, true); + this.callback.taskSucceeded(); + } else { + this.status = "Failed"; + this.callback.taskDecision("[TASK FAILED] Decision: task failed (criteria not satisfied)"); + await this.closeExistingSession(ref.session, ref.id, false); + this.callback.taskFailed(); + } + } + + public async executeMultiple(): Promise { + // === MANAGED SESSION MODE (MULTIPLE MODELS) === + const drivingModelId = this.entry.models.driving; + const tModelId = this.taskModelId || this.entry.models.driving; + this.runtimeValues["task-model"] = tModelId; + + // Check availability + if (this.task.availability && !this.ignorePrerequisiteCheck) { + if (this.task.availability.condition) { + const [ds, dsId] = await this.openSession(drivingModelId, true); + const dRef = { session: ds, id: dsId }; + + const condPrompt = expandPrompt(this.entry, this.task.availability.condition, this.runtimeValues); + let condPassed: boolean; + try { + const result = await this.sendMonitoredPrompt(dRef, condPrompt, drivingModelId, true); + condPassed = result.booleanResult === true; + } catch (err) { + await this.closeExistingSession(dRef.session, dRef.id, false); + throw err; + } + + await this.closeExistingSession(dRef.session, dRef.id, condPassed); + + if (!condPassed) { + this.callback.taskDecision(`[AVAILABILITY] Availability check failed: condition not satisfied. Condition: ${JSON.stringify(this.task.availability.condition)}`); + this.status = "Failed"; + this.callback.taskFailed(); + return; + } + this.callback.taskDecision("[AVAILABILITY] Availability check passed"); + } + } + + // Execute prompt (task session) + const [ts, tsId] = await this.openSession(tModelId, false); + let taskRef = { session: ts, id: tsId }; + + const promptText = expandPrompt(this.entry, this.task.prompt, this.runtimeValues); + let { toolsCalled } = await this.sendMonitoredPrompt(taskRef, promptText, tModelId, false); + + // Close task session (mission done) + await this.closeExistingSession(taskRef.session, taskRef.id, true); + + // Check criteria (new driving session for condition if needed) + let { passed, missingTools } = this.criteria + ? await this.checkCriteriaResult(toolsCalled, undefined, drivingModelId) + : { passed: true, missingTools: [] as string[] }; + + // Retry loop + if (!passed && this.criteria?.failureAction) { + const maxRetries = this.criteria.failureAction.retryTimes; + for (let i = 0; i < maxRetries && !passed; i++) { + this.callback.taskDecision(`[OPERATION] Starting retry #${i + 1}`); + + // New task session for retry + const [rts, rtsId] = await this.openSession(tModelId, false); + taskRef = { session: rts, id: rtsId }; + + const retryPrompt = this.buildRetryPrompt(missingTools); + let retryToolsCalled: Set; + try { + const retryResult = await this.sendMonitoredPrompt(taskRef, retryPrompt, tModelId, false); + retryToolsCalled = retryResult.toolsCalled; + } catch (err) { + // Crash exhausting per-call budget is a failed iteration + this.callback.taskDecision(`[SESSION CRASHED] Session crash during retry #${i + 1}: ${errorToDetailedString(err)}`); + continue; + } + + // Close task session + await this.closeExistingSession(taskRef.session, taskRef.id, true); + + // Re-check criteria + const checkResult = await this.checkCriteriaResult(retryToolsCalled, undefined, drivingModelId); + passed = checkResult.passed; + missingTools = checkResult.missingTools; + + this.callback.taskDecision(passed + ? `[CRITERIA] Criteria passed on retry #${i + 1}` + : `[CRITERIA] Criteria failed on retry #${i + 1}`); + } + if (!passed) { + this.callback.taskDecision(`[DECISION] Retry budget drained after ${maxRetries} retries`); + } + } + + // Report result + if (passed) { + this.status = "Succeeded"; + this.callback.taskDecision("[TASK SUCCEEDED] Decision: task succeeded"); + this.callback.taskSucceeded(); + } else { + this.status = "Failed"; + this.callback.taskDecision("[TASK FAILED] Decision: task failed (criteria not satisfied)"); + this.callback.taskFailed(); + } + } + + public async handleExecutionError(err: any): Promise { + if (this.status === "Executing") { + this.status = "Failed"; + } + this.crashError = err; + this.callback.taskDecision(`[TASK FAILED] Task error: ${errorToDetailedString(err)}`); + + // Close all remaining active sessions + for (const [id, session] of this.activeSessions) { + await helperSessionStop(session).catch(() => { }); + if (!this.borrowingMode) this.callback.taskSessionStopped(session, id, false); + } + this.activeSessions.clear(); + + this.callback.taskFailed(); + throw err; + } + + public async execute(): Promise { + try { + if (this.borrowingMode) { + await this.executeBorrowing(); + } else if (this.singleModel) { + await this.executeSingle(); + } else { + await this.executeMultiple(); + } + } catch (err) { + await this.handleExecutionError(err); + } + } +} + +export async function startTask( + entry: Entry, + taskName: string, + userInput: string, + drivingSession: ICopilotSession | undefined, + ignorePrerequisiteCheck: boolean, + callback: ICopilotTaskCallback, + taskModelIdOverride: string | undefined, + workingDirectory: string | undefined, + exceptionHandler: (err: any) => void +): Promise { + + // Assert drivingSessionRetries[0].modelId === entry.models.driving + if ( + entry.drivingSessionRetries.length > 0 && + entry.drivingSessionRetries[0].modelId !== entry.models.driving + ) { + throw new Error( + `Assertion failed: entry.drivingSessionRetries[0].modelId ("${entry.drivingSessionRetries[0].modelId}") !== entry.models.driving ("${entry.models.driving}")` + ); + } + + const copilotTask = new CopilotTaskImpl( + entry, + taskName, + userInput, + drivingSession, + ignorePrerequisiteCheck, + callback, + taskModelIdOverride, + workingDirectory + ); + + copilotTask.execute().catch(exceptionHandler); + return copilotTask; +} + +// ---- Task State for Live API ---- + +interface TaskState { + taskId: string; + task: ICopilotTask | null; + entity: LiveEntityState; + taskError: string | null; + borrowingSessionMode: boolean; +} + +const tasks = new Map(); +let nextTaskId = 1; + +// Export for jobsApi.ts to register tasks created during job execution +export function registerJobTask(borrowingSessionMode: boolean): { taskId: string; entity: LiveEntityState; setTask: (t: ICopilotTask) => void; setError: (err: string) => void; setClosed: () => void; pushResponse: (resp: LiveResponse) => void } { + const taskId = `task-${nextTaskId++}`; + const entity = createLiveEntityState(getCountDownMs(), () => { + tasks.delete(taskId); + }); + const state: TaskState = { + taskId, + task: null, + entity, + taskError: null, + borrowingSessionMode, + }; + tasks.set(taskId, state); + return { + taskId, + entity, + setTask: (t: ICopilotTask) => { state.task = t; }, + setError: (err: string) => { state.taskError = err; }, + setClosed: () => { closeLiveEntity(state.entity); }, + pushResponse: (resp: LiveResponse) => { pushLiveResponse(state.entity, resp); }, + }; +} + +// ---- Task API Handlers ---- + +export async function apiTaskList( + entry: Entry, + req: http.IncomingMessage, + res: http.ServerResponse, +): Promise { + const taskList = Object.entries(entry.tasks).map(([name, task]) => ({ + name, + requireUserInput: task.requireUserInput, + })); + jsonResponse(res, 200, { tasks: taskList }); +} + +export async function apiTaskStart( + entry: Entry, + req: http.IncomingMessage, + res: http.ServerResponse, + taskName: string, + sessionId: string, +): Promise { + const session = helperGetSession(sessionId); + if (!session) { + jsonResponse(res, 200, { error: "SessionNotFound" }); + return; + } + + const body = await readBody(req); + const userInput = body; + + try { + const reg = registerJobTask(true); + + const taskCallback: ICopilotTaskCallback = { + taskSucceeded() { + reg.pushResponse({ callback: "taskSucceeded" }); + reg.setClosed(); + }, + taskFailed() { + reg.pushResponse({ callback: "taskFailed" }); + reg.setClosed(); + }, + taskDecision(reason: string) { + reg.pushResponse({ callback: "taskDecision", reason }); + }, + // Unavailable in borrowing session mode - won't be called + taskSessionStarted(taskSession: ICopilotSession, taskId: string, isDrivingSession: boolean) { + reg.pushResponse({ callback: "taskSessionStarted", sessionId: taskId, isDriving: isDrivingSession }); + }, + taskSessionStopped(taskSession: ICopilotSession, taskId: string, succeeded: boolean) { + reg.pushResponse({ callback: "taskSessionStopped", sessionId: taskId, succeeded }); + }, + }; + + const copilotTask = await startTask(entry, taskName, userInput, session, true, taskCallback, undefined, undefined, (err: unknown) => { + reg.setError(errorToDetailedString(err)); + reg.pushResponse({ taskError: errorToDetailedString(err) }); + reg.setClosed(); + }); + reg.setTask(copilotTask); + + jsonResponse(res, 200, { taskId: reg.taskId }); + } catch (err) { + jsonResponse(res, 200, { taskError: errorToDetailedString(err) }); + } +} + +export async function apiTaskStop( + req: http.IncomingMessage, + res: http.ServerResponse, + taskId: string, +): Promise { + const state = tasks.get(taskId); + if (!state) { + jsonResponse(res, 200, { error: "TaskNotFound" }); + return; + } + if (state.borrowingSessionMode) { + jsonResponse(res, 200, { error: "TaskCannotClose" }); + return; + } + if (state.task) state.task.stop(); + closeLiveEntity(state.entity); + jsonResponse(res, 200, { result: "Closed" }); +} + +export async function apiTaskLive( + req: http.IncomingMessage, + res: http.ServerResponse, + taskId: string, + token: string, +): Promise { + const state = tasks.get(taskId); + const response = await waitForLiveResponse( + state?.entity, + token, + 5000, + "TaskNotFound", + "TaskClosed", + ); + jsonResponse(res, 200, response); +} \ No newline at end of file diff --git a/.github/Agent/packages/CopilotPortal/test/api.test.mjs b/.github/Agent/packages/CopilotPortal/test/api.test.mjs new file mode 100644 index 00000000..b58f051e --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/test/api.test.mjs @@ -0,0 +1,1042 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASE = "http://localhost:8888"; + +async function fetchJson(urlPath, options) { + const res = await fetch(`${BASE}${urlPath}`, options); + return res.json(); +} + +async function getToken() { + const data = await fetchJson("/api/token"); + return data.token; +} + +// Helper: resolve test entry path +const testEntryPath = path.resolve(__dirname, "testEntry.json"); + +describe("API: /api/test", () => { + it("returns hello world message", async () => { + const data = await fetchJson("/api/test"); + assert.deepStrictEqual(data, { message: "Hello, world!" }); + }); +}); + +describe("API: /api/config", () => { + it("returns repoRoot as a non-empty string", async () => { + const data = await fetchJson("/api/config"); + assert.ok(typeof data.repoRoot === "string", "repoRoot should be a string"); + assert.ok(data.repoRoot.length > 0, "repoRoot should not be empty"); + }); +}); + +describe("API: /api/copilot/models", () => { + it("returns an array of models", async () => { + const data = await fetchJson("/api/copilot/models"); + assert.ok(Array.isArray(data.models), "models should be an array"); + assert.ok(data.models.length > 0, "should have at least one model"); + }); + + it("each model has name, id, multiplier", async () => { + const data = await fetchJson("/api/copilot/models"); + for (const m of data.models) { + assert.ok(typeof m.name === "string", `model name should be string: ${JSON.stringify(m)}`); + assert.ok(typeof m.id === "string", `model id should be string: ${JSON.stringify(m)}`); + assert.ok(typeof m.multiplier === "number", `model multiplier should be number: ${JSON.stringify(m)}`); + } + }); + + it("has at least one free model (multiplier=0)", async () => { + const data = await fetchJson("/api/copilot/models"); + const freeModels = data.models.filter((m) => m.multiplier === 0); + assert.ok(freeModels.length > 0, "should have at least one free model"); + }); + + it("gpt-5-mini model exists and is free", async () => { + const data = await fetchJson("/api/copilot/models"); + const model = data.models.find((m) => m.id === "gpt-5-mini"); + assert.ok(model, "gpt-5-mini model should exist"); + assert.strictEqual(model.multiplier, 0, "gpt-5-mini should be free"); + }); +}); + +describe("API: /api/token", () => { + it("returns a token string", async () => { + const data = await fetchJson("/api/token"); + assert.ok(typeof data.token === "string", "token should be a string"); + assert.ok(data.token.length > 0, "token should not be empty"); + }); + + it("returns different tokens on each call", async () => { + const data1 = await fetchJson("/api/token"); + const data2 = await fetchJson("/api/token"); + assert.notStrictEqual(data1.token, data2.token, "tokens should be unique"); + }); +}); + +describe("API: session not found errors", () => { + it("live returns SessionNotFound for invalid session", async () => { + const token = await getToken(); + const data = await fetchJson(`/api/copilot/session/nonexistent/live/${token}`); + assert.deepStrictEqual(data, { error: "SessionNotFound" }); + }); + + it("stop returns SessionNotFound for invalid session", async () => { + const data = await fetchJson("/api/copilot/session/nonexistent/stop"); + assert.deepStrictEqual(data, { error: "SessionNotFound" }); + }); + + it("query returns SessionNotFound for invalid session", async () => { + const data = await fetchJson("/api/copilot/session/nonexistent/query", { + method: "POST", + body: "test", + }); + assert.deepStrictEqual(data, { error: "SessionNotFound" }); + }); +}); + +describe("API: full session lifecycle", () => { + let freeModelId; + let sessionId; + + before(async () => { + const data = await fetchJson("/api/copilot/models"); + const freeModel = data.models.find((m) => m.multiplier === 0); + assert.ok(freeModel, "need a free model for testing"); + freeModelId = freeModel.id; + }); + + after(async () => { + if (sessionId) { + try { + await fetchJson(`/api/copilot/session/${sessionId}/stop`); + } catch { + // ignore + } + } + }); + + it("starts a session and returns sessionId", async () => { + const data = await fetchJson(`/api/copilot/session/start/${freeModelId}`, { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools", + }); + assert.ok(typeof data.sessionId === "string", "should return sessionId"); + sessionId = data.sessionId; + }); + + it("live returns HttpRequestTimeout when idle", async () => { + assert.ok(sessionId, "session must be started"); + const token = await getToken(); + const data = await fetchJson(`/api/copilot/session/${sessionId}/live/${token}`); + assert.strictEqual(data.error, "HttpRequestTimeout"); + }); + + it("live returns ParallelCallNotSupported for concurrent calls", async () => { + assert.ok(sessionId, "session must be started"); + const token = await getToken(); + const [first, second] = await Promise.all([ + fetchJson(`/api/copilot/session/${sessionId}/live/${token}`), + new Promise((r) => setTimeout(r, 100)).then(() => + fetchJson(`/api/copilot/session/${sessionId}/live/${token}`) + ), + ]); + assert.strictEqual(second.error, "ParallelCallNotSupported", + "second parallel live call should be rejected"); + assert.strictEqual(first.error, "HttpRequestTimeout", + "first live call should still timeout normally"); + }); + + it("query sends request and returns empty object (no error)", async () => { + assert.ok(sessionId, "session must be started"); + const data = await fetchJson(`/api/copilot/session/${sessionId}/query`, { + method: "POST", + body: "What is 2+2? Reply with a single number only.", + }); + assert.strictEqual(data.error, undefined, "query should not return error"); + }); + + it("live returns callbacks after query", async () => { + assert.ok(sessionId, "session must be started"); + const callbacks = []; + let gotAgentEnd = false; + const token = await getToken(); + + const timeout = Date.now() + 60000; + while (!gotAgentEnd && Date.now() < timeout) { + const data = await fetchJson(`/api/copilot/session/${sessionId}/live/${token}`); + if (data.error === "HttpRequestTimeout") continue; + if (data.error) break; + if (data.responses) { + for (const r of data.responses) { + callbacks.push(r); + if (r.callback === "onAgentEnd") gotAgentEnd = true; + } + } + } + + assert.ok(gotAgentEnd, "should receive onAgentEnd callback"); + + const agentStart = callbacks.find((c) => c.callback === "onAgentStart"); + assert.ok(agentStart, "should receive onAgentStart"); + + const hasMessage = callbacks.some((c) => c.callback === "onStartMessage"); + const hasReasoning = callbacks.some((c) => c.callback === "onStartReasoning"); + assert.ok(hasMessage || hasReasoning, "should receive at least message or reasoning callbacks"); + }); + + it("stops the session and returns Closed", async () => { + assert.ok(sessionId, "session must be started"); + const data = await fetchJson(`/api/copilot/session/${sessionId}/stop`); + assert.deepStrictEqual(data, { result: "Closed" }); + sessionId = null; + }); + + it("stopping again returns SessionNotFound", async () => { + const data = await fetchJson("/api/copilot/session/session-that-was-just-stopped/stop"); + assert.deepStrictEqual(data, { error: "SessionNotFound" }); + }); +}); + +describe("API: session start errors", () => { + it("returns ModelIdNotFound for invalid model id", async () => { + const data = await fetchJson("/api/copilot/session/start/nonexistent-model-xyz", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools", + }); + assert.deepStrictEqual(data, { error: "ModelIdNotFound" }); + }); + + it("returns WorkingDirectoryNotAbsolutePath for relative path", async () => { + const modelsData = await fetchJson("/api/copilot/models"); + const freeModel = modelsData.models.find((m) => m.multiplier === 0); + assert.ok(freeModel, "need a free model"); + const data = await fetchJson(`/api/copilot/session/start/${freeModel.id}`, { + method: "POST", + body: "relative/path/here", + }); + assert.deepStrictEqual(data, { error: "WorkingDirectoryNotAbsolutePath" }); + }); + + it("returns WorkingDirectoryNotExists for non-existent path", async () => { + const modelsData = await fetchJson("/api/copilot/models"); + const freeModel = modelsData.models.find((m) => m.multiplier === 0); + assert.ok(freeModel, "need a free model"); + const data = await fetchJson(`/api/copilot/session/start/${freeModel.id}`, { + method: "POST", + body: "C:\\NonExistentPath\\ThatDoesNotExist12345", + }); + assert.deepStrictEqual(data, { error: "WorkingDirectoryNotExists" }); + }); +}); + +describe("API: task not found errors", () => { + it("task stop returns TaskNotFound for invalid task id", async () => { + const data = await fetchJson("/api/copilot/task/nonexistent/stop"); + assert.deepStrictEqual(data, { error: "TaskNotFound" }); + }); + + it("task live returns TaskNotFound for invalid task id", async () => { + const token = await getToken(); + const data = await fetchJson(`/api/copilot/task/nonexistent/live/${token}`); + assert.deepStrictEqual(data, { error: "TaskNotFound" }); + }); + +}); + +describe("API: copilot/test/installJobsEntry", () => { + it("returns error for tasks/jobs before entry is installed", async () => { + const tasks = await fetchJson("/api/copilot/task"); + assert.ok(tasks.error, "should return error when entry not installed"); + const jobs = await fetchJson("/api/copilot/job"); + assert.ok(jobs.error, "should return error when entry not installed"); + }); + + it("returns InvalidatePath for file outside test folder", async () => { + const data = await fetchJson("/api/copilot/test/installJobsEntry", { + method: "POST", + body: "C:\\some\\random\\path\\entry.json", + }); + assert.strictEqual(data.result, "InvalidatePath"); + assert.ok(data.error, "should have error message"); + }); + + it("returns InvalidatePath for non-existent file in test folder", async () => { + const fakePath = path.join(__dirname, "nonexistent.json"); + const data = await fetchJson("/api/copilot/test/installJobsEntry", { + method: "POST", + body: fakePath, + }); + assert.strictEqual(data.result, "InvalidatePath"); + }); + + it("returns InvalidateEntry for invalid entry JSON", async () => { + const invalidEntryPath = path.join(__dirname, "invalidEntry.json"); + const fs = await import("node:fs"); + fs.writeFileSync(invalidEntryPath, JSON.stringify({ + models: {}, + promptVariables: {}, + grid: [], + tasks: { + "bad-task": { model: { category: "nonexistent" }, prompt: ["hello"], requireUserInput: false } + }, + jobs: {} + })); + try { + const data = await fetchJson("/api/copilot/test/installJobsEntry", { + method: "POST", + body: invalidEntryPath, + }); + assert.strictEqual(data.result, "InvalidateEntry"); + assert.ok(data.error, "should have validation error"); + } finally { + fs.unlinkSync(invalidEntryPath); + } + }); + + it("installs a valid test entry successfully", async () => { + const data = await fetchJson("/api/copilot/test/installJobsEntry", { + method: "POST", + body: testEntryPath, + }); + assert.strictEqual(data.result, "OK", `installJobsEntry should succeed: ${JSON.stringify(data)}`); + }); + + it("rejects when session is running", async () => { + const modelsData = await fetchJson("/api/copilot/models"); + const freeModel = modelsData.models.find((m) => m.multiplier === 0); + const sessionData = await fetchJson(`/api/copilot/session/start/${freeModel.id}`, { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools", + }); + assert.ok(sessionData.sessionId, "should get session id"); + + try { + const data = await fetchJson("/api/copilot/test/installJobsEntry", { + method: "POST", + body: testEntryPath, + }); + assert.strictEqual(data.result, "Rejected"); + assert.ok(data.error, "should have error"); + } finally { + await fetchJson(`/api/copilot/session/${sessionData.sessionId}/stop`); + } + }); +}); + +describe("API: /api/copilot/task (after entry installed)", () => { + it("task start returns SessionNotFound for invalid session id", async () => { + const data = await fetchJson("/api/copilot/task/start/some-task/session/nonexistent", { + method: "POST", + body: "test input", + }); + assert.deepStrictEqual(data, { error: "SessionNotFound" }); + }); + + it("returns a list of tasks", async () => { + const data = await fetchJson("/api/copilot/task"); + assert.ok(Array.isArray(data.tasks), "tasks should be an array"); + assert.ok(data.tasks.length > 0, "should have at least one task"); + }); + + it("each task has name and requireUserInput", async () => { + const data = await fetchJson("/api/copilot/task"); + for (const t of data.tasks) { + assert.ok(typeof t.name === "string", `task name should be string: ${JSON.stringify(t)}`); + assert.ok(typeof t.requireUserInput === "boolean", `task requireUserInput should be boolean: ${JSON.stringify(t)}`); + } + }); + + it("contains expected test tasks", async () => { + const data = await fetchJson("/api/copilot/task"); + const taskNames = data.tasks.map((t) => t.name); + assert.ok(taskNames.includes("simple-task"), "should have simple-task"); + assert.ok(taskNames.includes("criteria-fail-task"), "should have criteria-fail-task"); + assert.ok(taskNames.includes("input-task"), "should have input-task"); + }); +}); + +describe("API: /api/copilot/job (after entry installed)", () => { + it("returns grid and jobs", async () => { + const data = await fetchJson("/api/copilot/job"); + assert.ok(Array.isArray(data.grid), "grid should be an array"); + assert.ok(typeof data.jobs === "object" && data.jobs !== null && !Array.isArray(data.jobs), "jobs should be an object"); + assert.ok(Object.keys(data.jobs).length > 0, "should have at least one job"); + }); + + it("grid has expected rows", async () => { + const data = await fetchJson("/api/copilot/job"); + assert.ok(data.grid.length >= 2, "should have at least 2 grid rows"); + const keywords = data.grid.map(r => r.keyword); + assert.ok(keywords.includes("test"), "should have 'test' keyword"); + assert.ok(keywords.includes("batch"), "should have 'batch' keyword"); + }); + + it("each job has work", async () => { + const data = await fetchJson("/api/copilot/job"); + for (const [name, job] of Object.entries(data.jobs)) { + assert.ok(typeof name === "string", `job key should be string`); + assert.ok(job.work !== undefined, `job ${name} should have work`); + } + }); + + it("contains expected test jobs", async () => { + const data = await fetchJson("/api/copilot/job"); + const jobNames = Object.keys(data.jobs); + assert.ok(jobNames.includes("simple-job"), "should have simple-job"); + assert.ok(jobNames.includes("seq-job"), "should have seq-job"); + assert.ok(jobNames.includes("par-job"), "should have par-job"); + assert.ok(jobNames.includes("fail-job"), "should have fail-job"); + }); + + it("does not include models, promptVariables, or tasks", async () => { + const data = await fetchJson("/api/copilot/job"); + assert.strictEqual(data.models, undefined, "should not include models"); + assert.strictEqual(data.promptVariables, undefined, "should not include promptVariables"); + assert.strictEqual(data.tasks, undefined, "should not include tasks"); + }); + + it("includes chart map with entries for each job", async () => { + const data = await fetchJson("/api/copilot/job"); + assert.ok(typeof data.chart === "object" && data.chart !== null, "chart should be an object"); + const jobNames = Object.keys(data.jobs); + const chartNames = Object.keys(data.chart); + assert.deepStrictEqual(chartNames.sort(), jobNames.sort(), "chart keys should match job keys"); + for (const [name, chartGraph] of Object.entries(data.chart)) { + assert.ok(Array.isArray(chartGraph.nodes), `chart[${name}] should have nodes array`); + } + }); + + it("every TaskWork has a ChartNode with TaskNode or CondNode hint", async () => { + const data = await fetchJson("/api/copilot/job"); + for (const [jobName, job] of Object.entries(data.jobs)) { + const taskWorkIds = []; + function collectTaskWorkIds(work) { + if (work.kind === "Ref") taskWorkIds.push(work.workIdInJob); + else if (work.kind === "Seq" || work.kind === "Par") work.works.forEach(collectTaskWorkIds); + else if (work.kind === "Loop") { + if (work.preCondition) collectTaskWorkIds(work.preCondition[1]); + collectTaskWorkIds(work.body); + if (work.postCondition) collectTaskWorkIds(work.postCondition[1]); + } else if (work.kind === "Alt") { + collectTaskWorkIds(work.condition); + if (work.trueWork) collectTaskWorkIds(work.trueWork); + if (work.falseWork) collectTaskWorkIds(work.falseWork); + } + } + collectTaskWorkIds(job.work); + const chart = data.chart[jobName]; + for (const wid of taskWorkIds) { + const node = chart.nodes.find(n => Array.isArray(n.hint) && (n.hint[0] === "TaskNode" || n.hint[0] === "CondNode") && n.hint[1] === wid); + assert.ok(node, `job ${jobName}: TaskWork workIdInJob=${wid} should have a ChartNode with TaskNode or CondNode hint`); + } + } + }); +}); + +describe("API: job not found errors", () => { + it("job start returns JobNotFound for invalid job name", async () => { + const data = await fetchJson("/api/copilot/job/start/nonexistent-job-xyz", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest input", + }); + assert.deepStrictEqual(data, { error: "JobNotFound" }); + }); + + it("job stop returns JobNotFound for invalid job id", async () => { + const data = await fetchJson("/api/copilot/job/nonexistent/stop"); + assert.deepStrictEqual(data, { error: "JobNotFound" }); + }); + + it("job live returns JobNotFound for invalid job id", async () => { + const token = await getToken(); + const data = await fetchJson(`/api/copilot/job/nonexistent/live/${token}`); + assert.deepStrictEqual(data, { error: "JobNotFound" }); + }); +}); + +// Helper: drain all live responses until a specific callback or timeout +async function drainLive(livePath, targetCallback, timeoutMs = 120000) { + const token = await getToken(); + const callbacks = []; + const deadline = Date.now() + timeoutMs; + let done = false; + while (Date.now() < deadline && !done) { + const data = await fetchJson(`${livePath}/${token}`); + if (data.error === "HttpRequestTimeout") continue; + if (data.error === "TaskNotFound" || data.error === "TaskClosed") break; + if (data.error === "JobNotFound" || data.error === "JobsClosed") break; + if (data.responses) { + for (const r of data.responses) { + callbacks.push(r); + if (r.callback === targetCallback || r.taskError || r.jobError) { + done = true; + break; + } + } + } + } + return callbacks; +} + +describe("API: task running - criteria success", () => { + let sessionId; + + before(async () => { + const modelsData = await fetchJson("/api/copilot/models"); + const freeModel = modelsData.models.find((m) => m.multiplier === 0); + assert.ok(freeModel, "need a free model"); + const data = await fetchJson(`/api/copilot/session/start/${freeModel.id}`, { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools", + }); + assert.ok(data.sessionId, "should get session id"); + sessionId = data.sessionId; + }); + + after(async () => { + if (sessionId) { + try { await fetchJson(`/api/copilot/session/${sessionId}/stop`); } catch { /* ignore */ } + } + }); + + it("simple-task succeeds (no criteria)", async () => { + const startData = await fetchJson(`/api/copilot/task/start/simple-task/session/${sessionId}`, { + method: "POST", + body: "", + }); + assert.ok(startData.taskId, `should return taskId: ${JSON.stringify(startData)}`); + + const callbacks = await drainLive(`/api/copilot/task/${startData.taskId}/live`, "taskSucceeded"); + const succeeded = callbacks.some((c) => c.callback === "taskSucceeded"); + assert.ok(succeeded, `simple-task should succeed, callbacks: ${JSON.stringify(callbacks.map(c => c.callback))}`); + + // taskDecision should appear with success message + const decisionCb = callbacks.find((c) => c.callback === "taskDecision"); + assert.ok(decisionCb, `should have taskDecision callback, callbacks: ${JSON.stringify(callbacks.map(c => c.callback))}`); + assert.ok(decisionCb.reason.includes("succeeded"), `taskDecision reason should mention succeeded: ${decisionCb.reason}`); + }); +}); + +describe("API: task running - criteria failure (no retry)", () => { + let sessionId; + + before(async () => { + const modelsData = await fetchJson("/api/copilot/models"); + const freeModel = modelsData.models.find((m) => m.multiplier === 0); + assert.ok(freeModel, "need a free model"); + const data = await fetchJson(`/api/copilot/session/start/${freeModel.id}`, { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools", + }); + assert.ok(data.sessionId, "should get session id"); + sessionId = data.sessionId; + }); + + after(async () => { + if (sessionId) { + try { await fetchJson(`/api/copilot/session/${sessionId}/stop`); } catch { /* ignore */ } + } + }); + + it("criteria-fail-task fails (toolExecuted not satisfied, retry=0)", async () => { + const startData = await fetchJson(`/api/copilot/task/start/criteria-fail-task/session/${sessionId}`, { + method: "POST", + body: "", + }); + assert.ok(startData.taskId, `should return taskId: ${JSON.stringify(startData)}`); + + const callbacks = await drainLive(`/api/copilot/task/${startData.taskId}/live`, "taskFailed"); + const failed = callbacks.some((c) => c.callback === "taskFailed"); + assert.ok(failed, `criteria-fail-task should fail, callbacks: ${JSON.stringify(callbacks.map(c => c.callback))}`); + + // In borrowing mode, no taskSessionStarted callbacks + const sessionStarts = callbacks.filter((c) => c.callback === "taskSessionStarted"); + assert.strictEqual(sessionStarts.length, 0, `should have no session starts in borrowing mode, got: ${sessionStarts.length}`); + + // taskDecision should report criteria failure and final decision + const decisions = callbacks.filter((c) => c.callback === "taskDecision"); + assert.ok(decisions.length >= 1, `should have taskDecision callbacks: ${JSON.stringify(callbacks.map(c => c.callback))}`); + const failDecision = decisions.find((d) => d.reason.includes("failed")); + assert.ok(failDecision, `should have a failure decision: ${JSON.stringify(decisions.map(d => d.reason))}`); + }); +}); + +describe("API: task running - live responses", () => { + let sessionId; + + before(async () => { + const modelsData = await fetchJson("/api/copilot/models"); + const freeModel = modelsData.models.find((m) => m.multiplier === 0); + assert.ok(freeModel, "need a free model"); + const data = await fetchJson(`/api/copilot/session/start/${freeModel.id}`, { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools", + }); + assert.ok(data.sessionId, "should get session id"); + sessionId = data.sessionId; + }); + + after(async () => { + if (sessionId) { + try { await fetchJson(`/api/copilot/session/${sessionId}/stop`); } catch { /* ignore */ } + } + }); + + it("task live responds correctly for succeeded task", async () => { + const startData = await fetchJson(`/api/copilot/task/start/simple-task/session/${sessionId}`, { + method: "POST", + body: "", + }); + assert.ok(startData.taskId, "should return taskId"); + + const callbacks = await drainLive(`/api/copilot/task/${startData.taskId}/live`, "taskSucceeded"); + + // In borrowing session mode, taskSessionStarted/Stopped are unavailable + const sessionStarted = callbacks.some((c) => c.callback === "taskSessionStarted"); + assert.ok(!sessionStarted, "should NOT have taskSessionStarted callback in borrowing mode"); + + const sessionStopped = callbacks.some((c) => c.callback === "taskSessionStopped"); + assert.ok(!sessionStopped, "should NOT have taskSessionStopped callback in borrowing mode"); + + const lastCallback = callbacks[callbacks.length - 1]; + assert.strictEqual(lastCallback.callback, "taskSucceeded", "last callback should be taskSucceeded"); + }); + + it("task live responds correctly for failed task", async () => { + const startData = await fetchJson(`/api/copilot/task/start/criteria-fail-task/session/${sessionId}`, { + method: "POST", + body: "", + }); + assert.ok(startData.taskId, "should return taskId"); + + const callbacks = await drainLive(`/api/copilot/task/${startData.taskId}/live`, "taskFailed"); + + const lastCallback = callbacks[callbacks.length - 1]; + assert.strictEqual(lastCallback.callback, "taskFailed", "last callback should be taskFailed"); + }); +}); + +describe("API: job running - simple job succeeds", () => { + it("simple-job succeeds (single TaskWork)", async () => { + const startData = await fetchJson("/api/copilot/job/start/simple-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, `should return jobId: ${JSON.stringify(startData)}`); + + const callbacks = await drainLive(`/api/copilot/job/${startData.jobId}/live`, "jobSucceeded"); + const succeeded = callbacks.some((c) => c.callback === "jobSucceeded"); + assert.ok(succeeded, `simple-job should succeed, callbacks: ${JSON.stringify(callbacks.map(c => c.callback))}`); + + const workStarted = callbacks.some((c) => c.callback === "workStarted"); + const workStopped = callbacks.some((c) => c.callback === "workStopped"); + assert.ok(workStarted, "should have workStarted"); + assert.ok(workStopped, "should have workStopped"); + + // workStarted should include taskId for live polling + const workStartedCb = callbacks.find((c) => c.callback === "workStarted"); + assert.ok(workStartedCb.taskId, "workStarted should include taskId"); + }); +}); + +describe("API: job running - sequential work", () => { + it("seq-job succeeds with sequential tasks", async () => { + const startData = await fetchJson("/api/copilot/job/start/seq-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, `should return jobId: ${JSON.stringify(startData)}`); + + const callbacks = await drainLive(`/api/copilot/job/${startData.jobId}/live`, "jobSucceeded"); + const succeeded = callbacks.some((c) => c.callback === "jobSucceeded"); + assert.ok(succeeded, `seq-job should succeed`); + + const workStarted = callbacks.filter((c) => c.callback === "workStarted"); + assert.strictEqual(workStarted.length, 2, "should have 2 workStarted for sequential tasks"); + }); +}); + +describe("API: job running - parallel work", () => { + it("par-job succeeds with parallel tasks", async () => { + const startData = await fetchJson("/api/copilot/job/start/par-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, `should return jobId: ${JSON.stringify(startData)}`); + + const callbacks = await drainLive(`/api/copilot/job/${startData.jobId}/live`, "jobSucceeded"); + const succeeded = callbacks.some((c) => c.callback === "jobSucceeded"); + assert.ok(succeeded, `par-job should succeed`); + + const workStarted = callbacks.filter((c) => c.callback === "workStarted"); + assert.strictEqual(workStarted.length, 2, "should have 2 workStarted for parallel tasks"); + }); +}); + +describe("API: job running - failure propagation", () => { + it("fail-job fails when task fails", async () => { + const startData = await fetchJson("/api/copilot/job/start/fail-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, `should return jobId: ${JSON.stringify(startData)}`); + + const callbacks = await drainLive(`/api/copilot/job/${startData.jobId}/live`, "jobFailed"); + const failed = callbacks.some((c) => c.callback === "jobFailed"); + assert.ok(failed, `fail-job should fail`); + + const workStopped = callbacks.find((c) => c.callback === "workStopped"); + assert.ok(workStopped, "should have workStopped"); + assert.strictEqual(workStopped.succeeded, false, "work should fail"); + }); +}); + +describe("API: job running - live responses observability", () => { + it("work execution is observable from live api", async () => { + const startData = await fetchJson("/api/copilot/job/start/simple-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, "should return jobId"); + + const callbacks = await drainLive(`/api/copilot/job/${startData.jobId}/live`, "jobSucceeded"); + + const startIdx = callbacks.findIndex((c) => c.callback === "workStarted"); + const stopIdx = callbacks.findIndex((c) => c.callback === "workStopped"); + const succeedIdx = callbacks.findIndex((c) => c.callback === "jobSucceeded"); + + assert.ok(startIdx >= 0, "should have workStarted"); + assert.ok(stopIdx > startIdx, "workStopped should come after workStarted"); + assert.ok(succeedIdx > stopIdx, "jobSucceeded should come after workStopped"); + }); +}); + +describe("API: job-created task live polling", () => { + it("task live API works for job-created tasks with taskDecision", async () => { + const startData = await fetchJson("/api/copilot/job/start/simple-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, "should return jobId"); + + // Drain job live to get the taskId from workStarted + const jobCallbacks = await drainLive(`/api/copilot/job/${startData.jobId}/live`, "jobSucceeded"); + + const workStartedCb = jobCallbacks.find((c) => c.callback === "workStarted"); + assert.ok(workStartedCb, "should have workStarted"); + assert.ok(workStartedCb.taskId, "workStarted should include taskId"); + assert.ok(workStartedCb.taskId.startsWith("task-"), "taskId should be in expected format"); + + // Drain task live - task may have already completed, try to get callbacks + const taskCallbacks = await drainLive(`/api/copilot/task/${workStartedCb.taskId}/live`, "taskSucceeded", 10000); + + // If we got callbacks, verify taskDecision appears + if (taskCallbacks.length > 0 && !taskCallbacks[0]?.error) { + const decisions = taskCallbacks.filter((c) => c.callback === "taskDecision"); + // taskDecision should be present for a succeeding task + if (decisions.length > 0) { + assert.ok(decisions.some((d) => d.reason), "taskDecision should have a reason"); + } + } + }); +}); + +describe("API: session lifecycle visibility - new token reads before SessionClosed", () => { + let freeModelId; + + before(async () => { + const data = await fetchJson("/api/copilot/models"); + const freeModel = data.models.find((m) => m.multiplier === 0); + assert.ok(freeModel, "need a free model for testing"); + freeModelId = freeModel.id; + }); + + it("a new token can read the first response during visible lifecycle", async () => { + // Start a session + const startData = await fetchJson(`/api/copilot/session/start/${freeModelId}`, { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools", + }); + assert.ok(startData.sessionId, "should get session id"); + const sessionId = startData.sessionId; + + try { + // Send a query so the session produces responses + await fetchJson(`/api/copilot/session/${sessionId}/query`, { + method: "POST", + body: "What is 1+1? Reply only with the number.", + }); + + // Read with token1 until we get at least one real callback + const token1 = await getToken(); + let token1Callbacks = []; + const deadline1 = Date.now() + 60000; + while (Date.now() < deadline1) { + const data = await fetchJson(`/api/copilot/session/${sessionId}/live/${token1}`); + if (data.error === "HttpRequestTimeout") continue; + if (data.error) break; + if (data.responses) { + let gotIdle = false; + for (const r of data.responses) { + token1Callbacks.push(r); + if (r.callback === "onIdle") { gotIdle = true; break; } + } + if (gotIdle) break; + } + } + assert.ok(token1Callbacks.length > 0, "token1 should have received at least one response"); + + // Stop the session (lifecycle countdown begins) + await fetchJson(`/api/copilot/session/${sessionId}/stop`); + + // Create a new token2 AFTER session stopped but before countdown expires + const token2 = await getToken(); + + // token2 should be able to read from position 0 (the first response) + const firstResponse = await fetchJson(`/api/copilot/session/${sessionId}/live/${token2}`); + assert.ok(!firstResponse.error || firstResponse.error === "SessionClosed", + `token2 should read a response or SessionClosed, got: ${JSON.stringify(firstResponse)}`); + + // If we got a batch response (not SessionClosed), verify it starts with position 0 + if (!firstResponse.error && firstResponse.responses) { + assert.strictEqual(firstResponse.responses[0].callback, token1Callbacks[0].callback, + "token2's first response should match token1's first response callback"); + + // Continue reading with token2 until SessionClosed + let token2Closed = false; + const deadline2 = Date.now() + 30000; + while (Date.now() < deadline2 && !token2Closed) { + const data = await fetchJson(`/api/copilot/session/${sessionId}/live/${token2}`); + if (data.error === "SessionClosed") { + token2Closed = true; + } else if (data.error === "HttpRequestTimeout") { + continue; + } else if (data.error) { + break; + } + } + assert.ok(token2Closed, "token2 should eventually receive SessionClosed"); + } + + // Drain token1 until SessionClosed as well + let token1Closed = false; + const deadline3 = Date.now() + 30000; + while (Date.now() < deadline3 && !token1Closed) { + const data = await fetchJson(`/api/copilot/session/${sessionId}/live/${token1}`); + if (data.error === "SessionClosed") { + token1Closed = true; + } else if (data.error === "HttpRequestTimeout") { + continue; + } else if (data.error) { + break; + } + } + assert.ok(token1Closed, "token1 should eventually receive SessionClosed"); + } finally { + // Cleanup: try to stop session if still running + try { await fetchJson(`/api/copilot/session/${sessionId}/stop`); } catch { /* ignore */ } + } + }); + + it("new token after countdown expires gets SessionNotFound", async () => { + // Start and immediately stop a session + const startData = await fetchJson(`/api/copilot/session/start/${freeModelId}`, { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools", + }); + assert.ok(startData.sessionId, "should get session id"); + const sessionId = startData.sessionId; + + await fetchJson(`/api/copilot/session/${sessionId}/stop`); + + // In test mode, countdown is 5 seconds. Wait for it to expire. + await new Promise((r) => setTimeout(r, 6000)); + + // A new token should now get SessionNotFound + const token = await getToken(); + const data = await fetchJson(`/api/copilot/session/${sessionId}/live/${token}`); + assert.strictEqual(data.error, "SessionNotFound", + "new token after countdown should get SessionNotFound"); + }); +}); + +describe("API: copilot/job/running", () => { + it("returns an array of jobs", async () => { + const data = await fetchJson("/api/copilot/job/running"); + assert.ok(data.jobs, "should have jobs array"); + assert.ok(Array.isArray(data.jobs), "jobs should be an array"); + }); + + it("running job appears in list", async () => { + const startData = await fetchJson("/api/copilot/job/start/simple-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, "should return jobId"); + + const data = await fetchJson("/api/copilot/job/running"); + const found = data.jobs.find((j) => j.jobId === startData.jobId); + assert.ok(found, "started job should appear in running list"); + assert.strictEqual(found.jobName, "simple-job", "jobName should match"); + assert.ok(found.startTime, "should have startTime"); + assert.ok(["Running", "Succeeded", "Failed", "Canceled"].includes(found.status), "should have valid status"); + + // Drain to let it finish + await drainLive(`/api/copilot/job/${startData.jobId}/live`, "jobSucceeded"); + }); + + it("finished job still appears within an hour", async () => { + const startData = await fetchJson("/api/copilot/job/start/simple-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, "should return jobId"); + + // Wait for it to finish + await drainLive(`/api/copilot/job/${startData.jobId}/live`, "jobSucceeded"); + + const data = await fetchJson("/api/copilot/job/running"); + const found = data.jobs.find((j) => j.jobId === startData.jobId); + assert.ok(found, "recently finished job should still appear"); + assert.ok(["Succeeded", "Failed"].includes(found.status), `finished job should have terminal status, got: ${found.status}`); + }); + + it("each job entry has required fields", async () => { + const data = await fetchJson("/api/copilot/job/running"); + for (const job of data.jobs) { + assert.ok(typeof job.jobId === "string", "jobId should be string"); + assert.ok(typeof job.jobName === "string", "jobName should be string"); + assert.ok(job.startTime, "should have startTime"); + assert.ok(["Running", "Succeeded", "Failed", "Canceled"].includes(job.status), `valid status: ${job.status}`); + } + }); +}); + +describe("API: copilot/job/{job-id}/status", () => { + it("returns JobNotFound for invalid job id", async () => { + const data = await fetchJson("/api/copilot/job/nonexistent-xyz/status"); + assert.deepStrictEqual(data, { error: "JobNotFound" }); + }); + + it("returns status for a running job", async () => { + const startData = await fetchJson("/api/copilot/job/start/simple-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, "should return jobId"); + + const statusData = await fetchJson(`/api/copilot/job/${startData.jobId}/status`); + assert.ok(!statusData.error, `should not have error: ${JSON.stringify(statusData)}`); + assert.strictEqual(statusData.jobId, startData.jobId, "jobId should match"); + assert.strictEqual(statusData.jobName, "simple-job", "jobName should match"); + assert.ok(statusData.startTime, "should have startTime"); + assert.ok(["Running", "Succeeded", "Failed", "Canceled"].includes(statusData.status), `valid status: ${statusData.status}`); + assert.ok(Array.isArray(statusData.tasks), "tasks should be an array"); + + // Drain to let it finish + await drainLive(`/api/copilot/job/${startData.jobId}/live`, "jobSucceeded"); + }); + + it("returns task statuses after job completes", async () => { + const startData = await fetchJson("/api/copilot/job/start/simple-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, "should return jobId"); + + // Wait for it to finish + await drainLive(`/api/copilot/job/${startData.jobId}/live`, "jobSucceeded"); + + const statusData = await fetchJson(`/api/copilot/job/${startData.jobId}/status`); + assert.ok(!statusData.error, `should not have error: ${JSON.stringify(statusData)}`); + assert.ok(statusData.tasks.length > 0, "should have at least one task"); + + // All tasks should be succeeded for a successful job + for (const task of statusData.tasks) { + assert.ok(typeof task.workIdInJob === "number", "workIdInJob should be number"); + assert.ok(["Running", "Succeeded", "Failed"].includes(task.status), `valid task status: ${task.status}`); + } + }); + + it("canceled job returns Canceled status", async () => { + const startData = await fetchJson("/api/copilot/job/start/simple-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, "should return jobId"); + + // Stop it immediately + await fetchJson(`/api/copilot/job/${startData.jobId}/stop`, { method: "POST" }); + + const statusData = await fetchJson(`/api/copilot/job/${startData.jobId}/status`); + assert.ok(!statusData.error, `should not have error: ${JSON.stringify(statusData)}`); + assert.strictEqual(statusData.status, "Canceled", "canceled job should show Canceled status"); + }); +}); + +describe("API: jobCanceled callback on stop", () => { + it("stopped job receives jobCanceled callback via live api", async () => { + const startData = await fetchJson("/api/copilot/job/start/simple-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, "should return jobId"); + + // Get a live token before stopping + const token = await getToken(); + + // Give the job a moment to start + await new Promise(r => setTimeout(r, 200)); + + // Stop the job + await fetchJson(`/api/copilot/job/${startData.jobId}/stop`, { method: "POST" }); + + // Drain live responses - should eventually get jobCanceled + const callbacks = []; + const deadline = Date.now() + 30000; + let done = false; + while (Date.now() < deadline && !done) { + const data = await fetchJson(`/api/copilot/job/${startData.jobId}/live/${token}`); + if (data.error === "HttpRequestTimeout") continue; + if (data.error === "JobNotFound" || data.error === "JobsClosed") break; + if (data.responses) { + for (const r of data.responses) { + callbacks.push(r); + if (r.callback === "jobCanceled" || r.callback === "jobFailed" || r.callback === "jobSucceeded") { done = true; break; } + if (r.jobError) { done = true; break; } + } + } + } + + const hasCanceled = callbacks.some(c => c.callback === "jobCanceled"); + assert.ok(hasCanceled, `stopped job should emit jobCanceled callback, got: ${JSON.stringify(callbacks.map(c => c.callback))}`); + }); +}); + +describe("API: finished job persists in running list", () => { + it("finished job appears in running list after live entity is drained", async () => { + const startData = await fetchJson("/api/copilot/job/start/simple-job", { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, "should return jobId"); + + // Drain all live responses to fully consume the entity + await drainLive(`/api/copilot/job/${startData.jobId}/live`, "jobSucceeded"); + + // Wait a moment for any cleanup to happen + await new Promise(r => setTimeout(r, 500)); + + // Job should still appear in the running list + const data = await fetchJson("/api/copilot/job/running"); + const found = data.jobs.find(j => j.jobId === startData.jobId); + assert.ok(found, "finished job should still appear in running list after draining live responses"); + assert.ok(["Succeeded", "Failed"].includes(found.status), `status should be terminal: ${found.status}`); + }); +}); + diff --git a/.github/Agent/packages/CopilotPortal/test/jobsData.test.mjs b/.github/Agent/packages/CopilotPortal/test/jobsData.test.mjs new file mode 100644 index 00000000..cd01cf07 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/test/jobsData.test.mjs @@ -0,0 +1,1058 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const { expandPromptStatic, expandPromptDynamic, validateEntry, availableTools, runtimeVariables } = + await import("../dist/jobsDef.js"); +const { generateChartNodes } = + await import("../dist/jobsChart.js"); +const { entry } = + await import("../dist/jobsData.js"); + +// Load and validate the test entry for tests that reference test-specific jobs +const testEntryRaw = JSON.parse(readFileSync(new URL("./testEntry.json", import.meta.url), "utf-8")); +const validatedTestEntry = validateEntry(JSON.parse(JSON.stringify(testEntryRaw)), "testEntry:"); + +describe("expandPromptStatic", () => { + it("joins prompt array with LF", () => { + const testEntry = { + ...entry, + promptVariables: {}, + }; + const result = expandPromptStatic(testEntry, "test", ["line1", "line2", "line3"]); + assert.deepStrictEqual(result, ["line1\nline2\nline3"]); + }); + + it("resolves prompt variables recursively", () => { + const testEntry = { + ...entry, + promptVariables: { + greeting: ["Hello"], + full: ["$greeting", "World"], + }, + }; + const result = expandPromptStatic(testEntry, "test", ["$full"]); + assert.deepStrictEqual(result, ["Hello\nWorld"]); + }); + + it("keeps runtime variables as-is", () => { + const testEntry = { + ...entry, + promptVariables: {}, + }; + const result = expandPromptStatic(testEntry, "test", ["Say $user-input please"]); + assert.deepStrictEqual(result, ["Say $user-input please"]); + }); + + it("throws on unknown variable", () => { + const testEntry = { + ...entry, + promptVariables: {}, + }; + assert.throws( + () => expandPromptStatic(testEntry, "test", ["$unknown-var"]), + { message: "test: Cannot find prompt variable: $unknown-var." } + ); + }); + + it("throws on empty prompt", () => { + assert.throws( + () => expandPromptStatic(entry, "test", []), + { message: "test: Prompt is empty." } + ); + }); + + it("codePath includes variable chain for nested errors", () => { + const testEntry = { + ...entry, + promptVariables: { + outer: ["$inner"], + inner: ["$missing"], + }, + }; + assert.throws( + () => expandPromptStatic(testEntry, "root", ["$outer"]), + { message: "root/$outer/$inner: Cannot find prompt variable: $missing." } + ); + }); +}); + +describe("expandPromptDynamic", () => { + it("resolves runtime variables from values", () => { + const result = expandPromptDynamic(entry, ["Hello $user-input"], { "user-input": "world" }); + assert.deepStrictEqual(result, ["Hello world"]); + }); + + it("throws when prompt has more than one item", () => { + assert.throws( + () => expandPromptDynamic(entry, ["a", "b"], {}), + /must have exactly one item/ + ); + }); + + it("returns when variable not in values", () => { + const result = expandPromptDynamic(entry, ["$user-input"], {}); + assert.deepStrictEqual(result, [""]); + }); +}); + +describe("validateEntry (entry export)", () => { + it("entry is exported and has expected structure", () => { + assert.ok(entry.models, "should have models"); + assert.ok(entry.grid, "should have grid"); + assert.ok(entry.tasks, "should have tasks"); + }); + + it("all task prompts are expanded to single item", () => { + for (const [name, task] of Object.entries(entry.tasks)) { + assert.strictEqual(task.prompt.length, 1, `task ${name} prompt should be single item`); + } + }); + + it("all availability conditions are expanded to single item", () => { + for (const [name, task] of Object.entries(entry.tasks)) { + if (task.availability?.condition) { + assert.strictEqual( + task.availability.condition.length, 1, + `task ${name} availability.condition should be single item` + ); + } + } + }); + + it("all criteria conditions are expanded to single item", () => { + for (const [name, task] of Object.entries(entry.tasks)) { + if (task.criteria?.condition) { + assert.strictEqual( + task.criteria.condition.length, 1, + `task ${name} criteria.condition should be single item` + ); + } + } + }); + + it("all criteria failureAction prompts are expanded to single item", () => { + for (const [name, task] of Object.entries(entry.tasks)) { + if (task.criteria?.failureAction && task.criteria.failureAction.length === 3) { + const prompt = task.criteria.failureAction[2]; + assert.strictEqual( + prompt.length, 1, + `task ${name} criteria.failureAction[2] should be single item` + ); + } + } + }); + + it("expanded prompts do not contain resolvable variables (only runtime ones)", () => { + for (const [name, task] of Object.entries(entry.tasks)) { + const text = task.prompt[0]; + const vars = text.match(/\$[a-zA-Z]+(?:-[a-zA-Z]+)*/g) || []; + for (const v of vars) { + assert.ok( + runtimeVariables.includes(v), + `task ${name} prompt contains unresolved variable: ${v}` + ); + } + } + }); + + it("validation error paths use JS expression format", () => { + // Build a minimal entry with an invalid model to test error path format + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2", coding: "gpt-5.2-codex", reviewers: [] }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "test-task": { model: { category: "nonexistent" }, prompt: ["hello"] } + } + }; + assert.throws( + () => { + validateEntry(badEntry, "root:"); + }, + (err) => { + return err.message.includes('root:entry.tasks["test-task"].model'); + } + ); + }); + + it("availableTools is a non-empty array of strings", () => { + assert.ok(Array.isArray(availableTools)); + assert.ok(availableTools.length > 0); + for (const t of availableTools) { + assert.ok(typeof t === "string"); + } + }); + + it("runtimeVariables is a non-empty array of strings starting with $", () => { + assert.ok(Array.isArray(runtimeVariables)); + assert.ok(runtimeVariables.length > 0); + for (const v of runtimeVariables) { + assert.ok(v.startsWith("$"), `${v} should start with $`); + } + }); +}); + +describe("expandPromptStatic requiresBooleanTool", () => { + it("passes when boolean tool is mentioned", () => { + const testEntry = { + ...entry, + promptVariables: {}, + }; + const result = expandPromptStatic(testEntry, "test", ["You must call job_boolean_true or job_boolean_false"], true); + assert.deepStrictEqual(result, ["You must call job_boolean_true or job_boolean_false"]); + }); + + it("passes when only job_boolean_true is mentioned", () => { + const testEntry = { + ...entry, + promptVariables: {}, + }; + const result = expandPromptStatic(testEntry, "test", ["Call job_boolean_true to confirm"], true); + assert.ok(result[0].includes("job_boolean_true")); + }); + + it("throws when no boolean tool is mentioned and requiresBooleanTool is true", () => { + const testEntry = { + ...entry, + promptVariables: {}, + }; + assert.throws( + () => expandPromptStatic(testEntry, "test", ["No boolean tool here"], true), + /Boolean tool/ + ); + }); + + it("does not throw when requiresBooleanTool is false or unset", () => { + const testEntry = { + ...entry, + promptVariables: {}, + }; + const result = expandPromptStatic(testEntry, "test", ["No boolean tool here"], false); + assert.deepStrictEqual(result, ["No boolean tool here"]); + const result2 = expandPromptStatic(testEntry, "test", ["No boolean tool here"]); + assert.deepStrictEqual(result2, ["No boolean tool here"]); + }); + + it("all availability conditions mention boolean tools", () => { + for (const [name, task] of Object.entries(entry.tasks)) { + if (task.availability?.condition) { + const text = task.availability.condition[0]; + assert.ok( + text.includes("job_boolean_true") || text.includes("job_boolean_false"), + `task ${name} availability.condition should mention boolean tool` + ); + } + } + }); + + it("all criteria conditions mention boolean tools", () => { + for (const [name, task] of Object.entries(entry.tasks)) { + if (task.criteria?.condition) { + const text = task.criteria.condition[0]; + assert.ok( + text.includes("job_boolean_true") || text.includes("job_boolean_false"), + `task ${name} criteria.condition should mention boolean tool` + ); + } + } + }); +}); + +describe("validateEntry requireUserInput", () => { + it("throws when requireUserInput is true but prompt does not use $user-input", () => { + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2", coding: "gpt-5.2-codex", reviewers: [] }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "test-task": { model: { category: "planning" }, requireUserInput: true, prompt: ["hello world"] } + } + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /requireUserInput.*should use \$user-input/ + ); + }); + + it("throws when requireUserInput is false but prompt uses $user-input", () => { + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2", coding: "gpt-5.2-codex", reviewers: [] }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "test-task": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello $user-input"] } + } + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /requireUserInput.*should not use \$user-input/ + ); + }); + + it("passes when requireUserInput matches prompt usage", () => { + const goodEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2", coding: "gpt-5.2-codex", reviewers: [] }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "task-with-input": { model: { category: "planning" }, requireUserInput: true, prompt: ["do $user-input"] }, + "task-without-input": { model: { category: "planning" }, requireUserInput: false, prompt: ["do something"] } + } + }; + const result = validateEntry(goodEntry, "test:"); + assert.ok(result); + }); + + it("validated entry tasks have correct requireUserInput for tasks using $user-input", () => { + for (const [name, task] of Object.entries(entry.tasks)) { + const text = task.prompt[0]; + const usesUserInput = text.includes("$user-input"); + assert.strictEqual( + task.requireUserInput, usesUserInput, + `task ${name}: requireUserInput=${task.requireUserInput} but prompt ${usesUserInput ? "uses" : "does not use"} $user-input` + ); + } + }); +}); + +const { assignWorkId } = await import("../dist/jobsDef.js"); + +describe("assignWorkId", () => { + it("assigns sequential ids to TaskWork nodes", () => { + const work = { + kind: "Seq", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "a" }, + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "b" }, + ], + }; + const result = assignWorkId(work); + assert.strictEqual(result.works[0].workIdInJob, 0); + assert.strictEqual(result.works[1].workIdInJob, 1); + }); + + it("assigns ids across nested structures", () => { + const work = { + kind: "Par", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "a" }, + { + kind: "Seq", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "b" }, + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "c" }, + ], + }, + ], + }; + const result = assignWorkId(work); + assert.strictEqual(result.works[0].workIdInJob, 0); + assert.strictEqual(result.works[1].works[0].workIdInJob, 1); + assert.strictEqual(result.works[1].works[1].workIdInJob, 2); + }); +}); + +describe("validateEntry models.driving", () => { + it("throws when models.driving is missing", () => { + const badEntry = { + models: { planning: "gpt-5.2" }, + promptVariables: {}, + grid: [], + tasks: {}, + jobs: {}, + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /entry\.models\.driving.*Should exist/ + ); + }); + + it("passes when models.driving exists", () => { + const goodEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: {}, + jobs: {}, + }; + const result = validateEntry(goodEntry, "test:"); + assert.ok(result); + }); + + it("throws when drivingSessionRetries[0].modelId doesn't match models.driving", () => { + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "wrong-model", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: {}, + jobs: {}, + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /drivingSessionRetries.*first modelId should equal/ + ); + }); + + it("passes when drivingSessionRetries[0].modelId matches models.driving", () => { + const goodEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: {}, + jobs: {}, + }; + const result = validateEntry(goodEntry, "test:"); + assert.ok(result); + }); + + it("throws when drivingSessionRetries is empty", () => { + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [], + promptVariables: {}, + grid: [], + tasks: {}, + jobs: {}, + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /drivingSessionRetries.*at least one item/ + ); + }); + + it("throws when drivingSessionRetries is undefined", () => { + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + promptVariables: {}, + grid: [], + tasks: {}, + jobs: {}, + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /drivingSessionRetries.*at least one item/ + ); + }); +}); + +describe("validateEntry jobs", () => { + it("validates TaskWork.taskId must be in entry.tasks", () => { + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "real-task": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + printedName: "Test Job", + work: assignWorkId({ kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "nonexistent-task" }), + }, + }, + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /taskId.*nonexistent-task.*not a valid task name/ + ); + }); + + it("validates TaskWork.modelOverride.category must be in entry.models", () => { + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "real-task": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + printedName: "Test Job", + work: assignWorkId({ kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "real-task", modelOverride: { category: "nonexistent-model" } }), + }, + }, + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /modelOverride.category.*nonexistent-model.*not a valid model key/ + ); + }); + + it("validates TaskWork.modelOverride must be defined if task has no model", () => { + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "no-model-task": { requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + printedName: "Test Job", + work: assignWorkId({ kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "no-model-task" }), + }, + }, + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /modelOverride.*must be defined/ + ); + }); + + it("passes validation for valid job with TaskWork", () => { + const goodEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "real-task": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + printedName: "Test Job", + work: assignWorkId({ kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "real-task" }), + }, + }, + }; + const result = validateEntry(goodEntry, "test:"); + assert.ok(result); + }); + + it("passes validation for valid job with modelOverride on task without model", () => { + const goodEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "no-model-task": { requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + printedName: "Test Job", + work: assignWorkId({ kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "no-model-task", modelOverride: { category: "planning" } }), + }, + }, + }; + const result = validateEntry(goodEntry, "test:"); + assert.ok(result); + }); +}); + +describe("validateEntry grid jobName", () => { + it("throws when grid jobName is not in entry.jobs", () => { + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [{ + keyword: "test", + jobs: [{ name: "col", jobName: "nonexistent-job" }] + }], + tasks: {}, + jobs: {}, + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /entry\.grid\[0\]\.jobs\[0\]\.jobName.*nonexistent-job.*not a valid job name/ + ); + }); + + it("passes when grid jobName exists in entry.jobs", () => { + const goodEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [{ + keyword: "test", + jobs: [{ name: "col", jobName: "real-job" }] + }], + tasks: { + "real-task": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "real-job": { + work: assignWorkId({ kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "real-task" }), + }, + }, + }; + const result = validateEntry(goodEntry, "test:"); + assert.ok(result); + }); + + it("all grid jobNames in entry are valid", () => { + const jobKeys = Object.keys(entry.jobs); + for (let rowIndex = 0; rowIndex < entry.grid.length; rowIndex++) { + const row = entry.grid[rowIndex]; + for (let colIndex = 0; colIndex < row.jobs.length; colIndex++) { + const col = row.jobs[colIndex]; + if (col === undefined || col === null) continue; // undefined renders an empty cell + assert.ok( + jobKeys.includes(col.jobName), + `grid[${rowIndex}].jobs[${colIndex}].jobName "${col.jobName}" should be a valid job name` + ); + } + } + }); +}); + +describe("validateEntry job requireUserInput", () => { + it("fills requireUserInput=false for job referencing only non-input tasks", () => { + const testEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "no-input-task": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + work: assignWorkId({ kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "no-input-task" }), + }, + }, + }; + const result = validateEntry(testEntry, "test:"); + assert.strictEqual(result.jobs["test-job"].requireUserInput, false); + }); + + it("fills requireUserInput=true for job referencing an input task", () => { + const testEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "input-task": { model: { category: "planning" }, requireUserInput: true, prompt: ["do $user-input"] }, + }, + jobs: { + "test-job": { + work: assignWorkId({ kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "input-task" }), + }, + }, + }; + const result = validateEntry(testEntry, "test:"); + assert.strictEqual(result.jobs["test-job"].requireUserInput, true); + }); + + it("fills requireUserInput=true for job with mixed tasks (Seq)", () => { + const testEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "no-input-task": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + "input-task": { model: { category: "planning" }, requireUserInput: true, prompt: ["do $user-input"] }, + }, + jobs: { + "test-job": { + work: assignWorkId({ + kind: "Seq", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "no-input-task" }, + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "input-task" }, + ] + }), + }, + }, + }; + const result = validateEntry(testEntry, "test:"); + assert.strictEqual(result.jobs["test-job"].requireUserInput, true); + }); + + it("passes when requireUserInput is defined correctly", () => { + const testEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "input-task": { model: { category: "planning" }, requireUserInput: true, prompt: ["do $user-input"] }, + }, + jobs: { + "test-job": { + requireUserInput: true, + work: assignWorkId({ kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "input-task" }), + }, + }, + }; + const result = validateEntry(testEntry, "test:"); + assert.strictEqual(result.jobs["test-job"].requireUserInput, true); + }); + + it("throws when requireUserInput is defined incorrectly", () => { + const testEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "no-input-task": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + requireUserInput: true, + work: assignWorkId({ kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "no-input-task" }), + }, + }, + }; + assert.throws( + () => validateEntry(testEntry, "test:"), + /requireUserInput.*Should be false but is true/ + ); + }); + + it("all jobs in entry have requireUserInput filled after validation", () => { + for (const [jobName, job] of Object.entries(entry.jobs)) { + assert.ok( + job.requireUserInput !== undefined, + `job ${jobName} should have requireUserInput filled` + ); + } + }); + + it("input-job in test entry has requireUserInput=true", () => { + assert.strictEqual(validatedTestEntry.jobs["input-job"].requireUserInput, true); + }); + + it("simple-job in test entry has requireUserInput=false", () => { + assert.strictEqual(validatedTestEntry.jobs["simple-job"].requireUserInput, false); + }); +}); + +describe("validateEntry work simplification", () => { + it("flattens nested SequentialWork", () => { + const testEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "t": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + work: assignWorkId({ + kind: "Seq", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + { + kind: "Seq", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + ] + } + ] + }), + }, + }, + }; + const result = validateEntry(testEntry, "test:"); + const work = result.jobs["test-job"].work; + assert.strictEqual(work.kind, "Seq"); + assert.strictEqual(work.works.length, 3, "nested Seq should be flattened"); + for (const w of work.works) { + assert.strictEqual(w.kind, "Ref"); + } + }); + + it("flattens multi-level nested SequentialWork", () => { + const testEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "t": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + work: assignWorkId({ + kind: "Seq", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + { + kind: "Seq", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + { + kind: "Seq", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + ] + } + ] + } + ] + }), + }, + }, + }; + const result = validateEntry(testEntry, "test:"); + const work = result.jobs["test-job"].work; + assert.strictEqual(work.kind, "Seq"); + assert.strictEqual(work.works.length, 3, "multi-level nested Seq should be fully flattened"); + }); + + it("flattens nested ParallelWork", () => { + const testEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "t": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + work: assignWorkId({ + kind: "Par", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + { + kind: "Par", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + ] + } + ] + }), + }, + }, + }; + const result = validateEntry(testEntry, "test:"); + const work = result.jobs["test-job"].work; + assert.strictEqual(work.kind, "Par"); + assert.strictEqual(work.works.length, 3, "nested Par should be flattened"); + }); + + it("does not flatten Par inside Seq or vice versa", () => { + const testEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "t": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + work: assignWorkId({ + kind: "Seq", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + { + kind: "Par", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + ] + } + ] + }), + }, + }, + }; + const result = validateEntry(testEntry, "test:"); + const work = result.jobs["test-job"].work; + assert.strictEqual(work.kind, "Seq"); + assert.strictEqual(work.works.length, 2, "Par inside Seq should not be flattened"); + assert.strictEqual(work.works[1].kind, "Par"); + }); + + it("nested-seq-job in test entry has flattened work", () => { + const work = validatedTestEntry.jobs["nested-seq-job"].work; + assert.strictEqual(work.kind, "Seq"); + assert.strictEqual(work.works.length, 3, "nested-seq-job should be fully flattened"); + }); + + it("nested-par-job in test entry has flattened work", () => { + const work = validatedTestEntry.jobs["nested-par-job"].work; + assert.strictEqual(work.kind, "Par"); + assert.strictEqual(work.works.length, 3, "nested-par-job should be fully flattened"); + }); +}); + +describe("validateEntry empty works rejection", () => { + it("throws when SequentialWork has empty works", () => { + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "t": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + work: assignWorkId({ kind: "Seq", works: [] }), + }, + }, + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /works.*should have at least one element/ + ); + }); + + it("throws when ParallelWork has empty works", () => { + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "t": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + work: assignWorkId({ kind: "Par", works: [] }), + }, + }, + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /works.*should have at least one element/ + ); + }); + + it("throws when nested ParallelWork inside SequentialWork has empty works", () => { + const badEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "t": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + work: assignWorkId({ + kind: "Seq", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + { kind: "Par", works: [] }, + ], + }), + }, + }, + }; + assert.throws( + () => validateEntry(badEntry, "test:"), + /works.*should have at least one element/ + ); + }); + + it("passes when SequentialWork has at least one element", () => { + const goodEntry = { + models: { driving: "gpt-5-mini", planning: "gpt-5.2" }, + drivingSessionRetries: [{ modelId: "gpt-5-mini", retries: 3 }], + promptVariables: {}, + grid: [], + tasks: { + "t": { model: { category: "planning" }, requireUserInput: false, prompt: ["hello"] }, + }, + jobs: { + "test-job": { + work: assignWorkId({ + kind: "Seq", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "t" }, + ], + }), + }, + }, + }; + const result = validateEntry(goodEntry, "test:"); + assert.ok(result); + }); +}); + +describe("generateChartNodes", () => { + it("generates TaskNode for each TaskWork", () => { + const work = assignWorkId({ + kind: "Seq", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "a" }, + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "b" }, + ], + }); + const chart = generateChartNodes(work); + const taskNodes = chart.nodes.filter(n => Array.isArray(n.hint) && n.hint[0] === "TaskNode"); + assert.strictEqual(taskNodes.length, 2, "should have 2 TaskNode nodes"); + const workIds = taskNodes.map(n => n.hint[1]).sort(); + assert.deepStrictEqual(workIds, [0, 1]); + }); + + it("generates ParBegin and ParEnd for ParallelWork", () => { + const work = assignWorkId({ + kind: "Par", + works: [ + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "a" }, + { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "b" }, + ], + }); + const chart = generateChartNodes(work); + assert.ok(chart.nodes.some(n => n.hint === "ParBegin"), "should have ParBegin"); + assert.ok(chart.nodes.some(n => n.hint === "ParEnd"), "should have ParEnd"); + }); + + it("generates LoopEnd for LoopWork", () => { + const work = assignWorkId({ + kind: "Loop", + body: { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "a" }, + }); + const chart = generateChartNodes(work); + assert.ok(chart.nodes.some(n => n.hint === "LoopEnd"), "should have LoopEnd"); + }); + + it("generates AltEnd for AltWork", () => { + const work = assignWorkId({ + kind: "Alt", + condition: { kind: "Ref", workIdInJob: /** @type {never} */ (undefined), taskId: "a" }, + }); + const chart = generateChartNodes(work); + assert.ok(chart.nodes.some(n => n.hint === "AltEnd"), "should have AltEnd"); + }); + + it("every TaskWork in test entry jobs has a ChartNode with TaskNode or CondNode hint", () => { + function collectTaskWorkIds(work) { + const ids = []; + if (work.kind === "Ref") ids.push(work.workIdInJob); + else if (work.kind === "Seq" || work.kind === "Par") work.works.forEach(w => ids.push(...collectTaskWorkIds(w))); + else if (work.kind === "Loop") { + if (work.preCondition) ids.push(...collectTaskWorkIds(work.preCondition[1])); + ids.push(...collectTaskWorkIds(work.body)); + if (work.postCondition) ids.push(...collectTaskWorkIds(work.postCondition[1])); + } else if (work.kind === "Alt") { + ids.push(...collectTaskWorkIds(work.condition)); + if (work.trueWork) ids.push(...collectTaskWorkIds(work.trueWork)); + if (work.falseWork) ids.push(...collectTaskWorkIds(work.falseWork)); + } + return ids; + } + for (const [jobName, job] of Object.entries(validatedTestEntry.jobs)) { + const workIds = collectTaskWorkIds(job.work); + const chart = generateChartNodes(job.work); + for (const wid of workIds) { + const node = chart.nodes.find(n => Array.isArray(n.hint) && (n.hint[0] === "TaskNode" || n.hint[0] === "CondNode") && n.hint[1] === wid); + assert.ok(node, `job ${jobName}: TaskWork workIdInJob=${wid} should have a ChartNode with TaskNode or CondNode hint`); + } + } + }); +}); diff --git a/.github/Agent/packages/CopilotPortal/test/liveOptimize.test.mjs b/.github/Agent/packages/CopilotPortal/test/liveOptimize.test.mjs new file mode 100644 index 00000000..aabe794c --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/test/liveOptimize.test.mjs @@ -0,0 +1,311 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { createLiveEntityState, pushLiveResponse, waitForLiveResponse, closeLiveEntity } from "../dist/sharedApi.js"; + +// Helper: create an entity and push a sequence of responses, return entity +function pushAll(entity, responses) { + for (const r of responses) { + pushLiveResponse(entity, r); + } + return entity; +} + +// Helper: create a fresh entity with large countdown +function makeEntity() { + return createLiveEntityState(60000); +} + +// Helper: register a token by calling waitForLiveResponse (it will create the token) +// Then drain immediately available responses +async function registerAndDrainToken(entity, token) { + const result = await waitForLiveResponse(entity, token, 1, "NotFound", "Closed"); + return result; +} + +describe("Live optimization: onEndReasoning removes deltas", () => { + it("removes all onReasoning deltas between onStartReasoning and onEndReasoning", () => { + const entity = makeEntity(); + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r1" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "a" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "b" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "c" }); + pushLiveResponse(entity, { callback: "onEndReasoning", reasoningId: "r1", completeContent: "abc" }); + + // Should have: onStartReasoning, onEndReasoning (deltas removed) + const callbacks = entity.responses.map(r => r.callback); + assert.ok(!callbacks.includes("onReasoning"), "onReasoning deltas should be removed"); + assert.ok(callbacks.includes("onStartReasoning"), "onStartReasoning should remain"); + assert.ok(callbacks.includes("onEndReasoning"), "onEndReasoning should remain"); + assert.strictEqual(entity.responses.length, 2); + }); + + it("only removes deltas for the matching id", () => { + const entity = makeEntity(); + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r1" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "x" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r2", delta: "y" }); // different id + pushLiveResponse(entity, { callback: "onEndReasoning", reasoningId: "r1", completeContent: "x" }); + + // r2 delta should remain + const r2Deltas = entity.responses.filter(r => r.callback === "onReasoning" && r.reasoningId === "r2"); + assert.strictEqual(r2Deltas.length, 1, "delta for r2 should remain untouched"); + }); + + it("stops at onStartReasoning boundary (does not cross into previous block)", () => { + const entity = makeEntity(); + // First reasoning block (already ended) + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r1" }); + pushLiveResponse(entity, { callback: "onEndReasoning", reasoningId: "r1", completeContent: "done" }); + // Stray delta from r2 before its start (edge case) + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r2" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r2", delta: "a" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r2", delta: "b" }); + pushLiveResponse(entity, { callback: "onEndReasoning", reasoningId: "r2", completeContent: "ab" }); + + // Both blocks should have start + end, no deltas + const r2Deltas = entity.responses.filter(r => r.callback === "onReasoning" && r.reasoningId === "r2"); + assert.strictEqual(r2Deltas.length, 0, "r2 deltas should be removed"); + assert.strictEqual(entity.responses.length, 4); // start1, end1, start2, end2 + }); + + it("adjusts token positions when deltas are removed", async () => { + const entity = makeEntity(); + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r1" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "a" }); + + // Register a token and drain (reads start + delta = position 2) + const result = await registerAndDrainToken(entity, "tok1"); + assert.strictEqual(result.responses.length, 2); + assert.strictEqual(entity.tokens.get("tok1").position, 2); + + // Push another delta (index 2 — unread by tok1; "a" at index 1 is all-read but only 1, no merge) + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "b" }); + assert.strictEqual(entity.tokens.get("tok1").position, 2); + + // Now push end — removes "a" (index 1) and "b" (index 2), adjusts token position + pushLiveResponse(entity, { callback: "onEndReasoning", reasoningId: "r1", completeContent: "ab" }); + + // entity now has [onStartReasoning, onEndReasoning] + assert.strictEqual(entity.responses.length, 2); + assert.strictEqual(entity.responses[0].callback, "onStartReasoning"); + assert.strictEqual(entity.responses[1].callback, "onEndReasoning"); + // Token read 2 items originally. "a" removed at index 1 (position was > 1 → adjusted). + // "b" removed at index 1 after previous removal. position 1 > 1? No. Position stays. + const tokenState = entity.tokens.get("tok1"); + assert.strictEqual(tokenState.position, 1, "token position should be adjusted after delta removal"); + }); +}); + +describe("Live optimization: onEndMessage removes deltas", () => { + it("removes all onMessage deltas between onStartMessage and onEndMessage", () => { + const entity = makeEntity(); + pushLiveResponse(entity, { callback: "onStartMessage", messageId: "m1" }); + pushLiveResponse(entity, { callback: "onMessage", messageId: "m1", delta: "hello" }); + pushLiveResponse(entity, { callback: "onMessage", messageId: "m1", delta: " world" }); + pushLiveResponse(entity, { callback: "onEndMessage", messageId: "m1", completeContent: "hello world" }); + + const callbacks = entity.responses.map(r => r.callback); + assert.ok(!callbacks.includes("onMessage"), "onMessage deltas should be removed"); + assert.strictEqual(entity.responses.length, 2); + }); +}); + +describe("Live optimization: onReasoning merges deltas (no tokens)", () => { + it("merges all deltas when no tokens are registered", () => { + const entity = makeEntity(); + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r1" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "a" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "b" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "c" }); + + // Should merge the 3 deltas into 1 + const deltas = entity.responses.filter(r => r.callback === "onReasoning"); + assert.strictEqual(deltas.length, 1, "should merge all deltas into one"); + assert.strictEqual(deltas[0].delta, "abc", "merged delta should concatenate all"); + }); + + it("does not merge across start boundary", () => { + const entity = makeEntity(); + // Block 1 ended + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r1" }); + pushLiveResponse(entity, { callback: "onEndReasoning", reasoningId: "r1", completeContent: "x" }); + // Block 2 in progress + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r2" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r2", delta: "a" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r2", delta: "b" }); + + const r2Deltas = entity.responses.filter(r => r.callback === "onReasoning" && r.reasoningId === "r2"); + assert.strictEqual(r2Deltas.length, 1, "r2 deltas should merge"); + assert.strictEqual(r2Deltas[0].delta, "ab"); + }); +}); + +describe("Live optimization: onReasoning merges deltas (with tokens)", () => { + it("merges unread deltas (>= largest token position)", async () => { + const entity = makeEntity(); + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r1" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "a" }); + + // Register token and drain (position = 2) + await registerAndDrainToken(entity, "tok1"); + + // Push more deltas — these are unread by tok1 + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "b" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "c" }); + + // The first delta at index 1 is at position < tok1.position (2), so it's "all-read" + // The delta at index 2 ("b") and the newly pushed "c" at index 3 are >= tok1.position + // "b" and "c" should merge together + const unreadDeltas = entity.responses.filter((r, i) => r.callback === "onReasoning" && i >= 2); + assert.strictEqual(unreadDeltas.length, 1, "unread deltas should merge"); + assert.strictEqual(unreadDeltas[0].delta, "bc", "merged unread delta"); + }); + + it("merges all-read deltas (< smallest token position)", async () => { + const entity = makeEntity(); + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r1" }); + + // Register token first so deltas aren't all merged immediately + await registerAndDrainToken(entity, "tok1"); + assert.strictEqual(entity.tokens.get("tok1").position, 1); + + // Push delta "a" (index 1, unread, only 1 → no merge) + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "a" }); + // Drain tok1 to position 2 + const d1 = await waitForLiveResponse(entity, "tok1", 1, "NotFound", "Closed"); + assert.strictEqual(d1.responses[0].delta, "a"); + assert.strictEqual(entity.tokens.get("tok1").position, 2); + + // Push delta "b" (index 2, unread 1, all-read "a" at 1 is only 1 → no merge) + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "b" }); + // Drain tok1 to position 3 + const d2 = await waitForLiveResponse(entity, "tok1", 1, "NotFound", "Closed"); + assert.strictEqual(d2.responses[0].delta, "b"); + assert.strictEqual(entity.tokens.get("tok1").position, 3); + + // Push delta "c" → now "a"(idx 1) and "b"(idx 2) are both < position 3 → merge! + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "c" }); + + const allDeltas = entity.responses.filter(r => r.callback === "onReasoning"); + assert.strictEqual(allDeltas.length, 2, "should have one merged read + one unread"); + assert.strictEqual(allDeltas[0].delta, "ab", "all-read deltas merged"); + assert.strictEqual(allDeltas[1].delta, "c", "unread delta remains"); + // Token position adjusted from 3 to 2 (one entry removed before position) + assert.strictEqual(entity.tokens.get("tok1").position, 2); + }); + + it("does not merge deltas between smallest and largest positions", async () => { + const entity = makeEntity(); + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r1" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "a" }); // index 1 + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "b" }); // index 2 + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "c" }); // index 3 + + // tok1 drains all (position = 4) + await registerAndDrainToken(entity, "tok1"); + // tok2 drains only first 2 (position = 2) — we need to create tok2 BEFORE adding more + // Actually positions are set on drain. Let's do it differently. + + const entity2 = makeEntity(); + pushLiveResponse(entity2, { callback: "onStartReasoning", reasoningId: "r1" }); + + // Register tok1 and tok2 by draining empty (will get HttpRequestTimeout or initial batch) + const r1 = await waitForLiveResponse(entity2, "tok1", 1, "NotFound", "Closed"); + const r2 = await waitForLiveResponse(entity2, "tok2", 1, "NotFound", "Closed"); + // Both tokens at position 1 (read onStartReasoning) + + // Push delta "a" at index 1 + pushLiveResponse(entity2, { callback: "onReasoning", reasoningId: "r1", delta: "a" }); + // Drain tok1 to position 2 + const d1 = await waitForLiveResponse(entity2, "tok1", 1, "NotFound", "Closed"); + assert.strictEqual(d1.responses[0].delta, "a"); + + // Now tok1 is at position 2, tok2 is at position 1 + // Push delta "b" — at index 2 + pushLiveResponse(entity2, { callback: "onReasoning", reasoningId: "r1", delta: "b" }); + + // "a" at index 1: >= smallest(1)? yes. < largest(2)? yes. → in between, don't merge + // "b" at index 2: >= largest(2)? yes. → unread group, but only 1 entry + // Nothing should merge + const deltas = entity2.responses.filter(r => r.callback === "onReasoning"); + assert.strictEqual(deltas.length, 2, "deltas between token positions should not merge"); + }); + + it("adjusts positions correctly after merging all-read deltas", async () => { + const entity = makeEntity(); + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r1" }); + + // Register token (position = 1 after reading start) + await registerAndDrainToken(entity, "tok1"); + + // Push and drain deltas one by one to avoid intermediate merging + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "a" }); + await waitForLiveResponse(entity, "tok1", 1, "NotFound", "Closed"); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "b" }); + await waitForLiveResponse(entity, "tok1", 1, "NotFound", "Closed"); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "c" }); + await waitForLiveResponse(entity, "tok1", 1, "NotFound", "Closed"); + + // Token at position 4, entity: [start, "ab", "c"] + // ("a" and "b" were merged when "c" was pushed, so position went from 4 to 3) + const posBefore = entity.tokens.get("tok1").position; + + // Push new delta "d" — triggers merge of all-read entries before posBefore + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "d" }); + + // Token position should have decreased if entries were merged + const posAfter = entity.tokens.get("tok1").position; + assert.ok(posAfter <= posBefore, "token position should be adjusted or unchanged after merge"); + + // All entries still accessible + const allDeltas = entity.responses.filter(r => r.callback === "onReasoning"); + const mergedContent = allDeltas.map(r => r.delta).join(""); + assert.strictEqual(mergedContent, "abcd", "all delta content preserved"); + }); +}); + +describe("Live optimization: onMessage merges deltas", () => { + it("merges message deltas identically to reasoning deltas", () => { + const entity = makeEntity(); + pushLiveResponse(entity, { callback: "onStartMessage", messageId: "m1" }); + pushLiveResponse(entity, { callback: "onMessage", messageId: "m1", delta: "hello" }); + pushLiveResponse(entity, { callback: "onMessage", messageId: "m1", delta: " " }); + pushLiveResponse(entity, { callback: "onMessage", messageId: "m1", delta: "world" }); + + const deltas = entity.responses.filter(r => r.callback === "onMessage"); + assert.strictEqual(deltas.length, 1, "should merge all message deltas"); + assert.strictEqual(deltas[0].delta, "hello world"); + }); + + it("onEndMessage removes onMessage deltas scoped to block", () => { + const entity = makeEntity(); + pushLiveResponse(entity, { callback: "onStartMessage", messageId: "m1" }); + pushLiveResponse(entity, { callback: "onMessage", messageId: "m1", delta: "hi" }); + pushLiveResponse(entity, { callback: "onStartMessage", messageId: "m2" }); + pushLiveResponse(entity, { callback: "onMessage", messageId: "m2", delta: "bye" }); + pushLiveResponse(entity, { callback: "onEndMessage", messageId: "m2", completeContent: "bye" }); + + // m2 deltas removed, m1 delta remains + const m1Deltas = entity.responses.filter(r => r.callback === "onMessage" && r.messageId === "m1"); + const m2Deltas = entity.responses.filter(r => r.callback === "onMessage" && r.messageId === "m2"); + assert.strictEqual(m1Deltas.length, 1, "m1 delta should remain"); + assert.strictEqual(m2Deltas.length, 0, "m2 deltas should be removed"); + }); +}); + +describe("Live optimization: interleaved reasoning and message", () => { + it("optimizations do not interfere across callback types", () => { + const entity = makeEntity(); + pushLiveResponse(entity, { callback: "onStartReasoning", reasoningId: "r1" }); + pushLiveResponse(entity, { callback: "onReasoning", reasoningId: "r1", delta: "think" }); + pushLiveResponse(entity, { callback: "onStartMessage", messageId: "m1" }); + pushLiveResponse(entity, { callback: "onMessage", messageId: "m1", delta: "say" }); + pushLiveResponse(entity, { callback: "onEndReasoning", reasoningId: "r1", completeContent: "think" }); + pushLiveResponse(entity, { callback: "onEndMessage", messageId: "m1", completeContent: "say" }); + + const callbacks = entity.responses.map(r => r.callback); + assert.ok(!callbacks.includes("onReasoning"), "reasoning deltas removed"); + assert.ok(!callbacks.includes("onMessage"), "message deltas removed"); + assert.strictEqual(entity.responses.length, 4); // 2 starts + 2 ends + }); +}); diff --git a/.github/Agent/packages/CopilotPortal/test/runTests.mjs b/.github/Agent/packages/CopilotPortal/test/runTests.mjs new file mode 100644 index 00000000..34214ea5 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/test/runTests.mjs @@ -0,0 +1,41 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageDir = path.resolve(__dirname, ".."); + +// Collect all test files to run +const testFiles = [ + path.join(__dirname, "liveOptimize.test.mjs"), + path.join(__dirname, "jobsData.test.mjs"), + path.join(__dirname, "api.test.mjs"), + path.join(__dirname, "work.test.mjs"), + path.join(__dirname, "web.test.mjs"), + path.join(__dirname, "web.index.mjs"), + path.join(__dirname, "web.jobs.mjs"), +]; + +async function runTests() { + return new Promise((resolve) => { + const child = spawn("node", ["--test", "--test-concurrency=1", ...testFiles], { + stdio: "inherit", + cwd: packageDir, + }); + child.on("close", (code) => resolve(code ?? 1)); + }); +} + +let exitCode = 0; +try { + exitCode = await runTests(); +} finally { + // Always stop the server, regardless of test outcome + try { + await fetch("http://localhost:8888/api/stop"); + console.log("Server stopped."); + } catch { + console.log("Server was already stopped or unreachable."); + } +} +process.exit(exitCode); diff --git a/.github/Agent/packages/CopilotPortal/test/startServer.mjs b/.github/Agent/packages/CopilotPortal/test/startServer.mjs new file mode 100644 index 00000000..ea81c1da --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/test/startServer.mjs @@ -0,0 +1,38 @@ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const serverScript = path.resolve(__dirname, "..", "dist", "index.js"); +const windowsHidePatch = path.resolve(__dirname, "windowsHide.cjs"); + +// Spawn server as detached process in test mode so it runs independently of this script. +// On Windows, the Copilot SDK internally spawns node.exe to run its bundled CLI server. +// Without windowsHide, each spawn creates a visible console window that steals keyboard focus. +// We use --require to preload a CJS patch (windowsHide.cjs) that adds windowsHide: true +// to all child_process.spawn calls before any ESM modules (including the SDK) are loaded. +const child = spawn("node", ["--require", windowsHidePatch, serverScript, "--test"], { + detached: true, + stdio: "ignore", + cwd: path.resolve(__dirname, ".."), + windowsHide: true, +}); +child.unref(); + +// Wait for server to be ready by polling api/test +const maxRetries = 30; +for (let i = 0; i < maxRetries; i++) { + try { + const res = await fetch("http://localhost:8888/api/test"); + if (res.ok) { + console.log("Server is ready at http://localhost:8888"); + process.exit(0); + } + } catch { + // Not ready yet + } + await new Promise((r) => setTimeout(r, 500)); +} + +console.error("Server failed to start within timeout"); +process.exit(1); diff --git a/.github/Agent/packages/CopilotPortal/test/testEntry.json b/.github/Agent/packages/CopilotPortal/test/testEntry.json new file mode 100644 index 00000000..f168fd78 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/test/testEntry.json @@ -0,0 +1,222 @@ +{ + "models": { + "driving": "gpt-5-mini", + "planning": "gpt-5-mini" + }, + "drivingSessionRetries": [ + { "modelId": "gpt-5-mini", "retries": 3 } + ], + "promptVariables": {}, + "grid": [ + { + "keyword": "test", + "jobs": [ + null, + { "name": "run", "jobName": "simple-job" }, + { "name": "fail", "jobName": "fail-job" }, + { "name": "input", "jobName": "input-job" } + ] + }, + { + "keyword": "batch", + "jobs": [ + { "name": "sequence", "jobName": "seq-job" }, + { "name": "parallel", "jobName": "par-job" } + ] + } + ], + "tasks": { + "simple-task": { + "model": { "category": "planning" }, + "prompt": ["Say hello and nothing else."], + "requireUserInput": false + }, + "criteria-fail-task": { + "model": { "category": "planning" }, + "prompt": ["Say hello and nothing else."], + "requireUserInput": false, + "criteria": { + "toolExecuted": ["job_prepare_document"], + "failureAction": { "retryTimes": 0 } + } + }, + "input-task": { + "model": { "category": "planning" }, + "prompt": ["Process this: $user-input"], + "requireUserInput": true + } + }, + "jobs": { + "simple-job": { + "work": { + "kind": "Ref", + "workIdInJob": 0, + "taskId": "simple-task" + } + }, + "input-job": { + "work": { + "kind": "Ref", + "workIdInJob": 0, + "taskId": "input-task" + } + }, + "nested-seq-job": { + "work": { + "kind": "Seq", + "works": [ + { "kind": "Ref", "workIdInJob": 0, "taskId": "simple-task" }, + { + "kind": "Seq", + "works": [ + { "kind": "Ref", "workIdInJob": 1, "taskId": "simple-task" }, + { + "kind": "Seq", + "works": [ + { "kind": "Ref", "workIdInJob": 2, "taskId": "simple-task" } + ] + } + ] + } + ] + } + }, + "nested-par-job": { + "work": { + "kind": "Par", + "works": [ + { "kind": "Ref", "workIdInJob": 0, "taskId": "simple-task" }, + { + "kind": "Par", + "works": [ + { "kind": "Ref", "workIdInJob": 1, "taskId": "simple-task" }, + { "kind": "Ref", "workIdInJob": 2, "taskId": "simple-task" } + ] + } + ] + } + }, + "seq-job": { + "work": { + "kind": "Seq", + "works": [ + { "kind": "Ref", "workIdInJob": 0, "taskId": "simple-task" }, + { "kind": "Ref", "workIdInJob": 1, "taskId": "simple-task" } + ] + } + }, + "par-job": { + "work": { + "kind": "Par", + "works": [ + { "kind": "Ref", "workIdInJob": 0, "taskId": "simple-task" }, + { "kind": "Ref", "workIdInJob": 1, "taskId": "simple-task" } + ] + } + }, + "fail-job": { + "work": { + "kind": "Ref", + "workIdInJob": 0, + "taskId": "criteria-fail-task" + } + }, + "seq-fail-first-job": { + "work": { + "kind": "Seq", + "works": [ + { "kind": "Ref", "workIdInJob": 0, "taskId": "criteria-fail-task" }, + { "kind": "Ref", "workIdInJob": 1, "taskId": "simple-task" } + ] + } + }, + "seq-fail-second-job": { + "work": { + "kind": "Seq", + "works": [ + { "kind": "Ref", "workIdInJob": 0, "taskId": "simple-task" }, + { "kind": "Ref", "workIdInJob": 1, "taskId": "criteria-fail-task" } + ] + } + }, + "par-fail-one-job": { + "work": { + "kind": "Par", + "works": [ + { "kind": "Ref", "workIdInJob": 0, "taskId": "simple-task" }, + { "kind": "Ref", "workIdInJob": 1, "taskId": "criteria-fail-task" } + ] + } + }, + "alt-true-job": { + "work": { + "kind": "Alt", + "condition": { "kind": "Ref", "workIdInJob": 0, "taskId": "simple-task" }, + "trueWork": { "kind": "Ref", "workIdInJob": 1, "taskId": "simple-task" } + } + }, + "alt-false-job": { + "work": { + "kind": "Alt", + "condition": { "kind": "Ref", "workIdInJob": 0, "taskId": "criteria-fail-task" }, + "falseWork": { "kind": "Ref", "workIdInJob": 1, "taskId": "simple-task" } + } + }, + "alt-true-undef-job": { + "work": { + "kind": "Alt", + "condition": { "kind": "Ref", "workIdInJob": 0, "taskId": "simple-task" }, + "falseWork": { "kind": "Ref", "workIdInJob": 1, "taskId": "simple-task" } + } + }, + "alt-false-undef-job": { + "work": { + "kind": "Alt", + "condition": { "kind": "Ref", "workIdInJob": 0, "taskId": "criteria-fail-task" }, + "trueWork": { "kind": "Ref", "workIdInJob": 1, "taskId": "simple-task" } + } + }, + "alt-true-fail-job": { + "work": { + "kind": "Alt", + "condition": { "kind": "Ref", "workIdInJob": 0, "taskId": "simple-task" }, + "trueWork": { "kind": "Ref", "workIdInJob": 1, "taskId": "criteria-fail-task" } + } + }, + "alt-false-fail-job": { + "work": { + "kind": "Alt", + "condition": { "kind": "Ref", "workIdInJob": 0, "taskId": "criteria-fail-task" }, + "falseWork": { "kind": "Ref", "workIdInJob": 1, "taskId": "criteria-fail-task" } + } + }, + "loop-pre-exit-job": { + "work": { + "kind": "Loop", + "preCondition": [true, { "kind": "Ref", "workIdInJob": 0, "taskId": "criteria-fail-task" }], + "body": { "kind": "Ref", "workIdInJob": 1, "taskId": "simple-task" } + } + }, + "loop-pre-enter-job": { + "work": { + "kind": "Loop", + "preCondition": [true, { "kind": "Ref", "workIdInJob": 0, "taskId": "simple-task" }], + "body": { "kind": "Ref", "workIdInJob": 1, "taskId": "simple-task" } + } + }, + "loop-body-fail-job": { + "work": { + "kind": "Loop", + "preCondition": [true, { "kind": "Ref", "workIdInJob": 0, "taskId": "simple-task" }], + "body": { "kind": "Ref", "workIdInJob": 1, "taskId": "criteria-fail-task" } + } + }, + "loop-post-exit-job": { + "work": { + "kind": "Loop", + "body": { "kind": "Ref", "workIdInJob": 0, "taskId": "simple-task" }, + "postCondition": [true, { "kind": "Ref", "workIdInJob": 1, "taskId": "criteria-fail-task" }] + } + } + } +} diff --git a/.github/Agent/packages/CopilotPortal/test/web.index.mjs b/.github/Agent/packages/CopilotPortal/test/web.index.mjs new file mode 100644 index 00000000..72ea71cf --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/test/web.index.mjs @@ -0,0 +1,340 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +const BASE = "http://localhost:8888"; + +describe("Web: index.html setup UI", () => { + let browser; + let page; + + before(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(BASE); + await page.waitForTimeout(2000); + }); + + after(async () => { + await browser?.close(); + }); + + it("shows setup UI on load", async () => { + const visible = await page.locator("#setup-ui").isVisible(); + assert.ok(visible, "setup UI should be visible"); + }); + + it("session UI is hidden on load", async () => { + const display = await page.evaluate(() => { + return getComputedStyle(document.getElementById("session-ui")).display; + }); + assert.strictEqual(display, "none"); + }); + + it("loads all 3 stylesheets", async () => { + const links = await page.evaluate(() => + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.getAttribute("href")) + ); + assert.ok(links.includes("index.css"), "should include index.css"); + assert.ok(links.includes("messageBlock.css"), "should include messageBlock.css"); + assert.ok(links.includes("sessionResponse.css"), "should include sessionResponse.css"); + }); + + it("populates model select with sorted models", async () => { + const options = await page.locator("#model-select option").allTextContents(); + assert.ok(options.length > 0, "should have model options"); + // Verify sorted + const sorted = [...options].sort((a, b) => a.localeCompare(b)); + assert.deepStrictEqual(options, sorted, "model options should be sorted"); + }); + + it("defaults model to gpt-5.2", async () => { + const selected = await page.locator("#model-select").inputValue(); + assert.strictEqual(selected, "gpt-5.2"); + }); + + it("has working directory defaulting to REPO-ROOT", async () => { + const value = await page.locator("#working-dir").inputValue(); + // Should be the repo root (non-empty, absolute path) + assert.ok(value.length > 0, "working dir should not be empty"); + // Verify it matches the server's config + const config = await (await fetch(`${BASE}/api/config`)).json(); + assert.strictEqual(value, config.repoRoot, "working dir should default to REPO-ROOT"); + }); + + it("has New Job and Refresh buttons on the left, Start button on the right", async () => { + const jobsButton = page.locator("#jobs-button"); + const refreshButton = page.locator("#refresh-button"); + const startButton = page.locator("#start-button"); + assert.ok(await jobsButton.isVisible(), "New Job button should be visible"); + assert.ok(await refreshButton.isVisible(), "Refresh button should be visible"); + assert.ok(await startButton.isVisible(), "Start button should be visible"); + assert.strictEqual(await jobsButton.textContent(), "New Job"); + assert.strictEqual(await refreshButton.textContent(), "Refresh"); + assert.strictEqual(await startButton.textContent(), "Start"); + + // Verify grouping: New Job and Refresh in left div, Start on the right + const leftDiv = page.locator("#setup-buttons-left"); + assert.ok(await leftDiv.count() > 0, "setup-buttons-left should exist"); + + // Verify button positions: left buttons should be left of Start + const jobsBox = await jobsButton.boundingBox(); + const startBox = await startButton.boundingBox(); + assert.ok(jobsBox.x < startBox.x, "New Job should be to the left of Start"); + }); + + it("Start button is enabled after models load", async () => { + const disabled = await page.locator("#start-button").isDisabled(); + assert.ok(!disabled, "Start button should be enabled after models are loaded"); + }); + + it("Jobs button navigates to jobs.html with working directory", async () => { + const wd = await page.locator("#working-dir").inputValue(); + // Intercept navigation by listening for the URL change + const [response] = await Promise.all([ + page.waitForURL(/\/jobs\.html\?wb=/), + page.locator("#jobs-button").click(), + ]); + const url = new URL(page.url()); + assert.strictEqual(url.pathname, "/jobs.html"); + assert.strictEqual(url.searchParams.get("wb"), wd); + }); +}); + +describe("Web: index.html running jobs list", () => { + let browser; + let page; + + before(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(BASE); + await page.waitForTimeout(2000); + }); + + after(async () => { + await browser?.close(); + }); + + it("running jobs list container exists", async () => { + const exists = await page.locator("#running-jobs-list").count(); + assert.ok(exists > 0, "running jobs list should exist"); + }); + + it("Refresh button populates running jobs list", async () => { + await page.locator("#refresh-button").click(); + await page.waitForTimeout(1000); + // Verify the list was populated (may be empty if no jobs running, but the fetch should work) + const listEl = page.locator("#running-jobs-list"); + const isVisible = await listEl.isVisible(); + assert.ok(isVisible, "running jobs list should be visible after refresh"); + }); +}); + +describe("Web: index.html project parameter", () => { + let browser; + let page; + + before(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(`${BASE}/index.html?project=TestProject`); + await page.waitForTimeout(1000); + }); + + after(async () => { + await browser?.close(); + }); + + it("sets working directory from project parameter", async () => { + const value = await page.locator("#working-dir").inputValue(); + // Should be REPO-ROOT\..\TestProject + const config = await (await fetch(`${BASE}/api/config`)).json(); + const repoRoot = config.repoRoot; + const sep = repoRoot.includes("\\") ? "\\" : "/"; + const parentIdx = Math.max(repoRoot.lastIndexOf("/"), repoRoot.lastIndexOf("\\")); + const parentDir = parentIdx > 0 ? repoRoot.substring(0, parentIdx) : repoRoot; + const expected = parentDir + sep + "TestProject"; + assert.strictEqual(value, expected, `working dir should be ${expected}`); + }); +}); + +describe("Web: index.html session interaction", () => { + let browser; + let page; + let sessionId; + const consoleErrors = []; + + before(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + page.on("console", (msg) => { + if (msg.type() === "error") consoleErrors.push(msg.text()); + }); + await page.goto(BASE); + await page.waitForTimeout(2000); + + // Find a free model + const models = await (await fetch(`${BASE}/api/copilot/models`)).json(); + const freeModel = models.models.find((m) => m.multiplier === 0); + assert.ok(freeModel, "need a free model for testing"); + + // Start session and capture actual sessionId from network response + await page.locator("#model-select").selectOption(freeModel.id); + await page.locator("#working-dir").fill("C:\\Code\\VczhLibraries\\Tools"); + const responsePromise = page.waitForResponse(resp => resp.url().includes("/api/copilot/session/start/")); + await page.locator("#start-button").click(); + const startResponse = await responsePromise; + const startData = await startResponse.json(); + sessionId = startData.sessionId; + await page.waitForTimeout(3000); + }); + + after(async () => { + // Stop session if still open + if (sessionId) { + try { + await fetch(`${BASE}/api/copilot/session/${sessionId}/stop`); + } catch { + // ignore + } + } + await browser?.close(); + }); + + it("hides setup UI and shows session UI after start", async () => { + const setupVisible = await page.locator("#setup-ui").isVisible(); + assert.ok(!setupVisible, "setup UI should be hidden"); + const sessionVisible = await page.locator("#session-ui").isVisible(); + assert.ok(sessionVisible, "session UI should be visible"); + }); + + it("session-part has session-response-container class from renderer", async () => { + const hasClass = await page.evaluate(() => + document.getElementById("session-part").classList.contains("session-response-container") + ); + assert.ok(hasClass, "session-part should have session-response-container class"); + }); + + it("awaiting status element exists inside session-part", async () => { + const exists = await page.evaluate(() => { + const sp = document.getElementById("session-part"); + return sp.querySelector(".session-response-awaiting") !== null; + }); + assert.ok(exists, "should have awaiting status element"); + }); + + it("awaiting status is hidden when session is idle", async () => { + const display = await page.evaluate(() => { + const sp = document.getElementById("session-part"); + return sp.querySelector(".session-response-awaiting").style.display; + }); + assert.strictEqual(display, "none", "awaiting should be hidden when idle"); + }); + + it("send button is enabled initially", async () => { + const disabled = await page.locator("#send-button").isDisabled(); + assert.ok(!disabled, "send button should be enabled"); + }); + + it("task select combo box exists with (none) default", async () => { + const taskSelect = page.locator("#task-select"); + const visible = await taskSelect.isVisible(); + assert.ok(visible, "task select should be visible"); + + const options = await taskSelect.locator("option").allTextContents(); + assert.ok(options.length >= 1, "should have at least (none) option"); + assert.strictEqual(options[0], "(none)", "first option should be (none)"); + + const selectedValue = await taskSelect.inputValue(); + assert.strictEqual(selectedValue, "", "(none) should be selected by default"); + }); + + it("sends a request and creates User message block", async () => { + await page.locator("#request-textarea").fill("What is 2+2? Just the number."); + await page.locator("#send-button").click(); + + // User block should appear immediately + await page.waitForTimeout(500); + const userBlocks = await page.locator('.message-block').count(); + assert.ok(userBlocks >= 1, "should have at least one message block (User)"); + + // First block should contain our text + const firstBlockText = await page.locator(".message-block .message-block-body").first().textContent(); + assert.ok(firstBlockText.includes("2+2"), "first block should contain user request"); + }); + + it("send button disabled while waiting for response", async () => { + // Should still be disabled from previous send + const disabled = await page.locator("#send-button").isDisabled(); + assert.ok(disabled, "send button should be disabled during response"); + }); + + it("awaiting status shown while waiting for response", async () => { + const display = await page.evaluate(() => { + const sp = document.getElementById("session-part"); + return sp.querySelector(".session-response-awaiting").style.display; + }); + assert.strictEqual(display, "block", "awaiting should be shown during response"); + }); + + it("response creates additional message blocks and re-enables send", async () => { + // Wait for response to complete + await page.waitForFunction( + () => !document.getElementById("send-button").disabled, + { timeout: 60000 } + ); + + const blockCount = await page.locator(".message-block").count(); + assert.ok(blockCount >= 2, `should have at least 2 message blocks (User + response), got ${blockCount}`); + + const sendDisabled = await page.locator("#send-button").isDisabled(); + assert.ok(!sendDisabled, "send button should be re-enabled"); + }); + + it("awaiting status hidden after response completes", async () => { + const display = await page.evaluate(() => { + const sp = document.getElementById("session-part"); + return sp.querySelector(".session-response-awaiting").style.display; + }); + assert.strictEqual(display, "none", "awaiting should be hidden after response"); + }); + + it("completed message blocks have correct CSS classes", async () => { + const blocks = await page.locator(".message-block.completed").count(); + assert.ok(blocks >= 2, "should have completed message blocks"); + }); + + it("User block has completed class and is expanded", async () => { + const userBlock = page.locator(".message-block").first(); + const hasCompleted = await userBlock.evaluate((el) => el.classList.contains("completed")); + assert.ok(hasCompleted, "User block should be completed"); + const isCollapsed = await userBlock.evaluate((el) => el.classList.contains("collapsed")); + assert.ok(!isCollapsed, "User block should be expanded"); + }); + + it("resize bar exists and is visible", async () => { + const visible = await page.locator("#resize-bar").isVisible(); + assert.ok(visible, "resize bar should be visible"); + }); + + it("request textarea is visible and functional", async () => { + const visible = await page.locator("#request-textarea").isVisible(); + assert.ok(visible, "textarea should be visible"); + await page.locator("#request-textarea").fill("test"); + const value = await page.locator("#request-textarea").inputValue(); + assert.strictEqual(value, "test"); + await page.locator("#request-textarea").fill(""); + }); + + it("Stop Server and Close Session buttons are visible", async () => { + const stopVisible = await page.locator("#stop-server-button").isVisible(); + assert.ok(stopVisible, "Stop Server button should be visible"); + const closeVisible = await page.locator("#close-session-button").isVisible(); + assert.ok(closeVisible, "Close Session button should be visible"); + }); + + it("no console errors during session", () => { + assert.strictEqual(consoleErrors.length, 0, `console errors: ${consoleErrors.join("; ")}`); + }); +}); diff --git a/.github/Agent/packages/CopilotPortal/test/web.jobs.mjs b/.github/Agent/packages/CopilotPortal/test/web.jobs.mjs new file mode 100644 index 00000000..280e9721 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/test/web.jobs.mjs @@ -0,0 +1,616 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +const BASE = "http://localhost:8888"; + +async function fetchJson(urlPath, options) { + const res = await fetch(`${BASE}${urlPath}`, options); + return res.json(); +} + +// Verify the entry is already installed (by api.test.mjs which runs earlier) +async function verifyEntryInstalled() { + const data = await fetchJson("/api/copilot/job"); + assert.ok(Array.isArray(data.grid), "grid should be an array (entry must be installed by api.test.mjs)"); + assert.ok(data.grid.length > 0, "grid should have rows (entry must be installed by api.test.mjs)"); +} + +describe("Web: jobs.html redirect", () => { + let browser; + let page; + + before(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + }); + + after(async () => { + await browser?.close(); + }); + + it("redirects to index.html when no wb parameter", async () => { + await page.goto(`${BASE}/jobs.html`); + await page.waitForTimeout(1000); + const url = new URL(page.url()); + assert.strictEqual(url.pathname, "/index.html", "should redirect to index.html"); + }); +}); + +describe("Web: jobs.html layout", () => { + let browser; + let page; + + before(async () => { + await verifyEntryInstalled(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(`${BASE}/jobs.html?wb=C%3A%5CCode%5CVczhLibraries%5CTools`); + await page.waitForTimeout(2000); + }); + + after(async () => { + await browser?.close(); + }); + + it("loads jobs.css stylesheet", async () => { + const links = await page.evaluate(() => + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.getAttribute("href")) + ); + assert.ok(links.includes("jobs.css"), "should include jobs.css"); + }); + + it("has left-part and right-part", async () => { + const leftVisible = await page.locator("#left-part").isVisible(); + const rightVisible = await page.locator("#right-part").isVisible(); + assert.ok(leftVisible, "left part should be visible"); + assert.ok(rightVisible, "right part should be visible"); + }); + + it("has horizontal resize bar", async () => { + const visible = await page.locator("#resize-bar").isVisible(); + assert.ok(visible, "resize bar should be visible"); + }); + + it("matrix-part is visible at start", async () => { + const visible = await page.locator("#matrix-part").isVisible(); + assert.ok(visible, "matrix part should be visible"); + }); + + it("user-input-part is visible at start", async () => { + const visible = await page.locator("#user-input-part").isVisible(); + assert.ok(visible, "user input part should be visible"); + }); + + it("user-input-textarea is disabled by default", async () => { + const disabled = await page.locator("#user-input-textarea").isDisabled(); + assert.ok(disabled, "textarea should be disabled by default"); + }); +}); + +describe("Web: jobs.html matrix rendering", () => { + let browser; + let page; + + before(async () => { + await verifyEntryInstalled(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(`${BASE}/jobs.html?wb=C%3A%5CCode%5CVczhLibraries%5CTools`); + await page.waitForTimeout(2000); + }); + + after(async () => { + await browser?.close(); + }); + + it("renders matrix table with title", async () => { + const title = await page.locator(".matrix-title").textContent(); + assert.strictEqual(title, "Available Jobs", "should have title row"); + }); + + it("has Stop Server button in title row", async () => { + const btn = page.locator("#stop-server-button"); + const visible = await btn.isVisible(); + assert.ok(visible, "Stop Server button should be visible"); + const text = await btn.textContent(); + assert.strictEqual(text, "Stop Server"); + }); + + it("renders keyword columns", async () => { + const keywords = await page.locator(".matrix-keyword").allTextContents(); + assert.ok(keywords.includes("test"), "should have 'test' keyword"); + assert.ok(keywords.includes("batch"), "should have 'batch' keyword"); + }); + + it("renders job buttons with correct text", async () => { + const jobBtnTexts = await page.locator(".matrix-job-btn").allTextContents(); + assert.ok(jobBtnTexts.includes("run"), "should have 'run' button"); + assert.ok(jobBtnTexts.includes("fail"), "should have 'fail' button"); + assert.ok(jobBtnTexts.includes("sequence"), "should have 'sequence' button"); + assert.ok(jobBtnTexts.includes("parallel"), "should have 'parallel' button"); + }); + + it("job buttons have data-job-name attribute", async () => { + const jobNames = await page.evaluate(() => + Array.from(document.querySelectorAll(".matrix-job-btn")).map(b => b.dataset.jobName) + ); + assert.ok(jobNames.includes("simple-job"), "should have simple-job"); + assert.ok(jobNames.includes("fail-job"), "should have fail-job"); + assert.ok(jobNames.includes("seq-job"), "should have seq-job"); + assert.ok(jobNames.includes("par-job"), "should have par-job"); + }); +}); + +describe("Web: jobs.html job selection", () => { + let browser; + let page; + + before(async () => { + await verifyEntryInstalled(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(`${BASE}/jobs.html?wb=C%3A%5CCode%5CVczhLibraries%5CTools`); + await page.waitForTimeout(2000); + }); + + after(async () => { + await browser?.close(); + }); + + it("Start Job button shows 'Job Not Selected' and is disabled initially", async () => { + const text = await page.locator("#start-job-button").textContent(); + assert.strictEqual(text, "Job Not Selected"); + const disabled = await page.locator("#start-job-button").isDisabled(); + assert.ok(disabled, "start job button should be disabled"); + }); + + it("clicking a job button selects it and enables start button", async () => { + const btn = page.locator('.matrix-job-btn[data-job-name="simple-job"]'); + await btn.click(); + const hasSelected = await btn.evaluate(el => el.classList.contains("selected")); + assert.ok(hasSelected, "clicked button should have selected class"); + const startText = await page.locator("#start-job-button").textContent(); + assert.strictEqual(startText, "Start Job: simple-job"); + const disabled = await page.locator("#start-job-button").isDisabled(); + assert.ok(!disabled, "start job button should be enabled"); + }); + + it("textarea stays disabled for job without requireUserInput", async () => { + // simple-job uses simple-task which has requireUserInput: false + const disabled = await page.locator("#user-input-textarea").isDisabled(); + assert.ok(disabled, "textarea should remain disabled for jobs without user input"); + }); + + it("clicking the same job button deselects it", async () => { + const btn = page.locator('.matrix-job-btn[data-job-name="simple-job"]'); + await btn.click(); // deselect + const hasSelected = await btn.evaluate(el => el.classList.contains("selected")); + assert.ok(!hasSelected, "button should not have selected class"); + const startText = await page.locator("#start-job-button").textContent(); + assert.strictEqual(startText, "Job Not Selected"); + const disabled = await page.locator("#start-job-button").isDisabled(); + assert.ok(disabled, "start job button should be disabled"); + const textareaDisabled = await page.locator("#user-input-textarea").isDisabled(); + assert.ok(textareaDisabled, "textarea should be disabled when deselected"); + }); + + it("clicking a different job button switches selection", async () => { + const btn1 = page.locator('.matrix-job-btn[data-job-name="simple-job"]'); + const btn2 = page.locator('.matrix-job-btn[data-job-name="seq-job"]'); + await btn1.click(); // select simple-job + await btn2.click(); // select seq-job (deselects simple-job) + const has1 = await btn1.evaluate(el => el.classList.contains("selected")); + const has2 = await btn2.evaluate(el => el.classList.contains("selected")); + assert.ok(!has1, "first button should not be selected"); + assert.ok(has2, "second button should be selected"); + const startText = await page.locator("#start-job-button").textContent(); + assert.strictEqual(startText, "Start Job: seq-job"); + }); + + it("textarea enables for job with requireUserInput", async () => { + // Select input-job which uses input-task with requireUserInput: true + const btn = page.locator('.matrix-job-btn[data-job-name="input-job"]'); + await btn.click(); + const textareaDisabled = await page.locator("#user-input-textarea").isDisabled(); + assert.ok(!textareaDisabled, "textarea should be enabled for jobs with requireUserInput"); + await btn.click(); // deselect + }); + + it("user input textarea is visible", async () => { + const visible = await page.locator("#user-input-textarea").isVisible(); + assert.ok(visible, "user input textarea should be visible"); + }); +}); + +describe("Web: jobTracking.html redirect", () => { + let browser; + let page; + + before(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + }); + + after(async () => { + await browser?.close(); + }); + + it("redirects to index.html when no jobName or jobId parameter", async () => { + await page.goto(`${BASE}/jobTracking.html`); + await page.waitForTimeout(1000); + const url = new URL(page.url()); + assert.strictEqual(url.pathname, "/index.html", "should redirect to index.html"); + }); + + it("does not redirect when only jobName is present (preview mode)", async () => { + await page.goto(`${BASE}/jobTracking.html?jobName=simple-job`); + await page.waitForTimeout(2000); + const url = new URL(page.url()); + assert.strictEqual(url.pathname, "/jobTracking.html", "should stay on jobTracking.html in preview mode"); + }); + + it("redirects when only jobId is present", async () => { + await page.goto(`${BASE}/jobTracking.html?jobId=fake-job-id`); + await page.waitForTimeout(1000); + const url = new URL(page.url()); + assert.strictEqual(url.pathname, "/index.html", "should redirect to index.html"); + }); +}); + +// Helper: start a job and return its jobId for use in jobTracking tests +async function startJobForTest(jobName = "simple-job") { + const data = await fetchJson(`/api/copilot/job/start/${jobName}`, { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + return data.jobId; +} + +describe("Web: jobTracking.html layout", () => { + let browser; + let page; + let testJobId; + + before(async () => { + await verifyEntryInstalled(); + testJobId = await startJobForTest(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(`${BASE}/jobTracking.html?jobName=simple-job&jobId=${encodeURIComponent(testJobId)}`); + await page.waitForTimeout(3000); + }); + + after(async () => { + await browser?.close(); + }); + + it("loads jobTracking.css stylesheet", async () => { + const links = await page.evaluate(() => + Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.getAttribute("href")) + ); + assert.ok(links.includes("jobTracking.css"), "should include jobTracking.css"); + }); + + it("has left-part and right-part", async () => { + const leftVisible = await page.locator("#left-part").isVisible(); + const rightVisible = await page.locator("#right-part").isVisible(); + assert.ok(leftVisible, "left part should be visible"); + assert.ok(rightVisible, "right part should be visible"); + }); + + it("has horizontal resize bar", async () => { + const visible = await page.locator("#resize-bar").isVisible(); + assert.ok(visible, "resize bar should be visible"); + }); + + it("job-part is visible", async () => { + const visible = await page.locator("#job-part").isVisible(); + assert.ok(visible, "job part should be visible"); + }); + + it("session-response-part shows chart JSON", async () => { + const text = await page.locator("#session-response-part").textContent(); + const parsed = JSON.parse(text); + assert.ok(parsed.job, "should have job definition"); + assert.ok(parsed.chart, "should have chart data"); + assert.ok(Array.isArray(parsed.chart.nodes), "chart nodes should be an array"); + assert.ok(parsed.chart.nodes.length > 0, "chart nodes should not be empty"); + }); +}); + +describe("Web: jobs.html Start Job opens new window", () => { + let browser; + let page; + + before(async () => { + await verifyEntryInstalled(); + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + page = await context.newPage(); + await page.goto(`${BASE}/jobs.html?wb=C%3A%5CCode%5CVczhLibraries%5CTools`); + await page.waitForTimeout(2000); + }); + + after(async () => { + await browser?.close(); + }); + + it("Start Job button calls API and opens jobTracking.html in a new window/tab", async () => { + // Select a job + const btn = page.locator('.matrix-job-btn[data-job-name="simple-job"]'); + await btn.click(); + await page.waitForTimeout(500); + + // Listen for new page (popup/tab) + const context = page.context(); + const [newPage] = await Promise.all([ + context.waitForEvent("page", { timeout: 30000 }), + page.locator("#start-job-button").click() + ]); + await newPage.waitForLoadState("domcontentloaded"); + const url = new URL(newPage.url()); + assert.strictEqual(url.pathname, "/jobTracking.html", "should open jobTracking.html"); + assert.strictEqual(url.searchParams.get("jobName"), "simple-job", "should pass jobName"); + assert.ok(url.searchParams.get("jobId"), "should pass jobId from start API"); + await newPage.close(); + }); +}); + +describe("Web: jobTracking.html Mermaid renderer", () => { + let browser; + let page; + let testJobId; + + before(async () => { + await verifyEntryInstalled(); + testJobId = await startJobForTest(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(`${BASE}/jobTracking.html?jobName=simple-job&jobId=${encodeURIComponent(testJobId)}`); + await page.waitForTimeout(3000); + }); + + after(async () => { + await browser?.close(); + }); + + it("renders SVG in job-part with Mermaid renderer", async () => { + const svgCount = await page.locator("#job-part svg").count(); + assert.ok(svgCount > 0, "should render an SVG element in job-part"); + }); + + it("session-response-part shows chart JSON with mermaid renderer", async () => { + const text = await page.locator("#session-response-part").textContent(); + const parsed = JSON.parse(text); + assert.ok(parsed.job, "should have job definition"); + assert.ok(parsed.chart, "should have chart data"); + }); +}); + +describe("Web: jobTracking.html TaskNode click interaction (Mermaid)", () => { + let browser; + let page; + let testJobId; + + before(async () => { + await verifyEntryInstalled(); + testJobId = await startJobForTest(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(`${BASE}/jobTracking.html?jobName=simple-job&jobId=${encodeURIComponent(testJobId)}`); + await page.waitForTimeout(3000); + }); + + after(async () => { + await browser?.close(); + }); + + it("clicking a TaskNode selects it with thicker border in Mermaid", async () => { + const nodeGroup = page.locator('#job-part svg g.node').first(); + await nodeGroup.click(); + await page.waitForTimeout(200); + const strokeWidth = await nodeGroup.locator('rect, polygon, circle, ellipse, path').first().evaluate(el => el.style.strokeWidth); + assert.strictEqual(strokeWidth, "5px", "should have thick stroke-width after click"); + }); + + it("clicking the same TaskNode again unselects it in Mermaid", async () => { + const nodeGroup = page.locator('#job-part svg g.node').first(); + await nodeGroup.click(); // unselect + await page.waitForTimeout(200); + const strokeWidth = await nodeGroup.locator('rect, polygon, circle, ellipse, path').first().evaluate(el => el.style.strokeWidth); + assert.ok(strokeWidth !== "5px", "should not have thick stroke-width after second click"); + }); + + it("clicking a TaskNode shows tab control in session response part", async () => { + const nodeGroup = page.locator('#job-part svg g.node').first(); + await nodeGroup.click(); // select / inspect + await page.waitForTimeout(500); + const tabContainer = page.locator('.tab-container'); + const isVisible = await tabContainer.isVisible(); + assert.ok(isVisible, "tab container should be visible when inspecting a task"); + + // Unselect to restore JSON view + await nodeGroup.click(); + await page.waitForTimeout(500); + }); + + it("clicking a TaskNode again restores JSON view in session response part", async () => { + // Make sure JSON is shown after unselect + const text = await page.locator("#session-response-part").textContent(); + const parsed = JSON.parse(text); + assert.ok(parsed.job, "should show job JSON when no task inspected"); + }); +}); + +describe("Web: jobTracking.html job status bar", () => { + let browser; + let page; + let testJobId; + + before(async () => { + await verifyEntryInstalled(); + testJobId = await startJobForTest(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(`${BASE}/jobTracking.html?jobName=simple-job&jobId=${encodeURIComponent(testJobId)}`); + await page.waitForTimeout(3000); + }); + + after(async () => { + await browser?.close(); + }); + + it("has job status label", async () => { + const label = page.locator("#job-status-label"); + const visible = await label.isVisible(); + assert.ok(visible, "job status label should be visible"); + const text = await label.textContent(); + assert.ok(text.startsWith("JOB:"), "label should start with JOB:"); + }); + + it("has Stop Job button", async () => { + const btn = page.locator("#stop-job-button"); + const visible = await btn.isVisible(); + assert.ok(visible, "Stop Job button should be visible"); + const text = await btn.textContent(); + assert.strictEqual(text, "Stop Job"); + }); +}); + +describe("Web: jobs.html Preview button", () => { + let browser; + let page; + + before(async () => { + await verifyEntryInstalled(); + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + page = await context.newPage(); + await page.goto(`${BASE}/jobs.html?wb=C%3A%5CCode%5CVczhLibraries%5CTools`); + await page.waitForTimeout(2000); + }); + + after(async () => { + await browser?.close(); + }); + + it("Preview button is disabled by default", async () => { + const disabled = await page.locator("#preview-button").isDisabled(); + assert.ok(disabled, "Preview button should be disabled by default"); + }); + + it("Preview button enables when a job is selected", async () => { + const btn = page.locator('.matrix-job-btn[data-job-name="simple-job"]'); + await btn.click(); + await page.waitForTimeout(200); + const disabled = await page.locator("#preview-button").isDisabled(); + assert.ok(!disabled, "Preview button should be enabled after selecting a job"); + }); + + it("Preview button disables when job is deselected", async () => { + const btn = page.locator('.matrix-job-btn[data-job-name="simple-job"]'); + await btn.click(); // deselect + await page.waitForTimeout(200); + const disabled = await page.locator("#preview-button").isDisabled(); + assert.ok(disabled, "Preview button should be disabled after deselecting"); + }); + + it("Preview button opens jobTracking.html without jobId", async () => { + const btn = page.locator('.matrix-job-btn[data-job-name="simple-job"]'); + await btn.click(); + await page.waitForTimeout(200); + + const context = page.context(); + const [newPage] = await Promise.all([ + context.waitForEvent("page", { timeout: 10000 }), + page.locator("#preview-button").click() + ]); + await newPage.waitForLoadState("domcontentloaded"); + const url = new URL(newPage.url()); + assert.strictEqual(url.pathname, "/jobTracking.html", "should open jobTracking.html"); + assert.strictEqual(url.searchParams.get("jobName"), "simple-job", "should pass jobName"); + assert.strictEqual(url.searchParams.get("jobId"), null, "should NOT pass jobId"); + await newPage.close(); + }); +}); + +describe("Web: jobTracking.html preview mode", () => { + let browser; + let page; + + before(async () => { + await verifyEntryInstalled(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(`${BASE}/jobTracking.html?jobName=simple-job`); + await page.waitForTimeout(3000); + }); + + after(async () => { + await browser?.close(); + }); + + it("shows JOB: PREVIEW status in preview mode", async () => { + const label = page.locator("#job-status-label"); + const text = await label.textContent(); + assert.strictEqual(text, "JOB: PREVIEW", "should show PREVIEW status"); + }); + + it("Stop Job button is not present in preview mode", async () => { + const count = await page.locator("#stop-job-button").count(); + assert.strictEqual(count, 0, "Stop Job button should not exist in preview mode"); + }); + + it("renders SVG flow chart in preview mode", async () => { + const svgCount = await page.locator("#job-part svg").count(); + assert.ok(svgCount > 0, "should render an SVG element in job-part"); + }); + + it("clicking ChartNode does nothing in preview mode", async () => { + const nodeGroup = page.locator('#job-part svg g.node').first(); + const nodeCount = await nodeGroup.count(); + if (nodeCount > 0) { + await nodeGroup.click(); + await page.waitForTimeout(500); + // In preview mode, clicking should not show tab container + const tabCount = await page.locator('.tab-container').count(); + assert.strictEqual(tabCount, 0, "tab container should not appear in preview mode"); + } + }); + + it("session-response-part shows chart JSON in preview mode", async () => { + const text = await page.locator("#session-response-part").textContent(); + const parsed = JSON.parse(text); + assert.ok(parsed.job, "should have job definition in preview mode"); + assert.ok(parsed.chart, "should have chart data in preview mode"); + }); +}); + +describe("Web: jobs.html handles undefined grid cells", () => { + let browser; + let page; + + before(async () => { + await verifyEntryInstalled(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(`${BASE}/jobs.html?wb=C%3A%5CCode%5CVczhLibraries%5CTools`); + await page.waitForTimeout(2000); + }); + + after(async () => { + await browser?.close(); + }); + + it("renders empty cells for undefined job entries without errors", async () => { + // The page should load without JS errors + const errors = []; + page.on("pageerror", (err) => errors.push(err.message)); + await page.waitForTimeout(500); + // Check that the matrix table exists and rendered + const tableVisible = await page.locator("#matrix-table").isVisible(); + assert.ok(tableVisible, "matrix table should render successfully"); + assert.strictEqual(errors.length, 0, "should have no JS errors"); + }); +}); diff --git a/.github/Agent/packages/CopilotPortal/test/web.test.mjs b/.github/Agent/packages/CopilotPortal/test/web.test.mjs new file mode 100644 index 00000000..a1c2b1ab --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/test/web.test.mjs @@ -0,0 +1,27 @@ +import { describe, it, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { chromium } from "playwright"; + +const BASE = "http://localhost:8888"; + +describe("Web: test.html", () => { + let browser; + let page; + + before(async () => { + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + }); + + after(async () => { + await browser?.close(); + }); + + it("loads test.html and shows Hello, world!", async () => { + await page.goto(`${BASE}/test.html`); + await page.waitForTimeout(2000); + const text = await page.locator("body").textContent(); + assert.strictEqual(text.trim(), "Hello, world!"); + }); +}); + diff --git a/.github/Agent/packages/CopilotPortal/test/windowsHide.cjs b/.github/Agent/packages/CopilotPortal/test/windowsHide.cjs new file mode 100644 index 00000000..92937800 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/test/windowsHide.cjs @@ -0,0 +1,16 @@ +// CJS preload: patch child_process.spawn on Windows to add windowsHide: true, +// preventing spawned console windows from stealing keyboard focus during tests. +"use strict"; + +if (process.platform === "win32") { + const cp = require("child_process"); + const originalSpawn = cp.spawn; + cp.spawn = function patchedSpawn(command, args, options) { + if (Array.isArray(args)) { + options = Object.assign({}, options, { windowsHide: true }); + } else if (args && typeof args === "object") { + args = Object.assign({}, args, { windowsHide: true }); + } + return originalSpawn.call(this, command, args, options); + }; +} diff --git a/.github/Agent/packages/CopilotPortal/test/work.test.mjs b/.github/Agent/packages/CopilotPortal/test/work.test.mjs new file mode 100644 index 00000000..804e53a9 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/test/work.test.mjs @@ -0,0 +1,425 @@ +import { describe, it, before } from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASE = "http://localhost:8888"; + +async function fetchJson(urlPath, options) { + const res = await fetch(`${BASE}${urlPath}`, options); + return res.json(); +} + +async function getToken() { + const data = await fetchJson("/api/token"); + return data.token; +} + +// Drain live responses until a specific callback or timeout +async function drainLive(livePath, targetCallback, timeoutMs = 120000) { + const token = await getToken(); + const callbacks = []; + const deadline = Date.now() + timeoutMs; + let done = false; + while (Date.now() < deadline && !done) { + const data = await fetchJson(`${livePath}/${token}`); + if (data.error === "HttpRequestTimeout") continue; + if (data.error === "JobNotFound" || data.error === "JobsClosed") break; + if (data.responses) { + for (const r of data.responses) { + callbacks.push(r); + if (r.callback === targetCallback || r.jobError) { + done = true; + break; + } + } + } + } + return callbacks; +} + +// Start a job and drain its live responses until completion +async function runJob(jobName) { + const startData = await fetchJson(`/api/copilot/job/start/${jobName}`, { + method: "POST", + body: "C:\\Code\\VczhLibraries\\Tools\ntest", + }); + assert.ok(startData.jobId, `should return jobId for ${jobName}: ${JSON.stringify(startData)}`); + + // Drain until jobSucceeded, jobFailed, or jobCanceled + const token = await getToken(); + const callbacks = []; + const deadline = Date.now() + 120000; + let done = false; + while (Date.now() < deadline && !done) { + const data = await fetchJson(`/api/copilot/job/${startData.jobId}/live/${token}`); + if (data.error === "HttpRequestTimeout") continue; + if (data.error === "JobNotFound" || data.error === "JobsClosed") break; + if (data.responses) { + for (const r of data.responses) { + callbacks.push(r); + if (r.callback === "jobSucceeded" || r.callback === "jobFailed" || r.callback === "jobCanceled") { + done = true; + break; + } + if (r.jobError) { + done = true; + break; + } + } + } + } + return { jobId: startData.jobId, callbacks }; +} + +// Extract workStarted/workStopped info from callbacks +function getWorkEvents(callbacks) { + const started = callbacks + .filter(c => c.callback === "workStarted") + .map(c => c.workId); + const stopped = callbacks + .filter(c => c.callback === "workStopped") + .map(c => ({ workId: c.workId, succeeded: c.succeeded })); + return { started, stopped }; +} + +// Look up workIdInJob values from the /api/copilot/job endpoint for a given job +async function getJobWorkIds(jobName) { + const jobsData = await fetchJson("/api/copilot/job"); + const job = jobsData.jobs[jobName]; + assert.ok(job, `job ${jobName} should exist`); + + // Collect all TaskWork workIdInJob values from the work tree + const workIds = []; + function collectWorkIds(work) { + if (work.kind === "Ref") { + workIds.push({ workId: work.workIdInJob, taskId: work.taskId }); + } else if (work.kind === "Seq" || work.kind === "Par") { + for (const w of work.works) collectWorkIds(w); + } else if (work.kind === "Loop") { + if (work.preCondition) collectWorkIds(work.preCondition[1]); + collectWorkIds(work.body); + if (work.postCondition) collectWorkIds(work.postCondition[1]); + } else if (work.kind === "Alt") { + collectWorkIds(work.condition); + if (work.trueWork) collectWorkIds(work.trueWork); + if (work.falseWork) collectWorkIds(work.falseWork); + } + } + collectWorkIds(job.work); + return workIds; +} + +// ============================================================ +// TaskWork +// ============================================================ + +describe("Work: TaskWork - succeeds", () => { + it("simple-job succeeds and workStarted/workStopped are observed", async () => { + const { callbacks } = await runJob("simple-job"); + const succeeded = callbacks.some(c => c.callback === "jobSucceeded"); + assert.ok(succeeded, "job should succeed"); + + const { started, stopped } = getWorkEvents(callbacks); + assert.strictEqual(started.length, 1, "should have 1 workStarted"); + assert.strictEqual(stopped.length, 1, "should have 1 workStopped"); + assert.strictEqual(stopped[0].succeeded, true, "work should succeed"); + }); +}); + +describe("Work: TaskWork - fails", () => { + it("fail-job fails when task fails", async () => { + const { callbacks } = await runJob("fail-job"); + const failed = callbacks.some(c => c.callback === "jobFailed"); + assert.ok(failed, "job should fail"); + + const { stopped } = getWorkEvents(callbacks); + assert.strictEqual(stopped.length, 1, "should have 1 workStopped"); + assert.strictEqual(stopped[0].succeeded, false, "work should fail"); + }); +}); + +// ============================================================ +// SequentialWork +// ============================================================ + +describe("Work: SequentialWork - all succeed", () => { + it("seq-job succeeds with all sequential tasks", async () => { + const { callbacks } = await runJob("seq-job"); + const succeeded = callbacks.some(c => c.callback === "jobSucceeded"); + assert.ok(succeeded, "job should succeed"); + + const { started, stopped } = getWorkEvents(callbacks); + assert.strictEqual(started.length, 2, "should have 2 workStarted"); + assert.strictEqual(stopped.length, 2, "should have 2 workStopped"); + assert.ok(stopped.every(s => s.succeeded), "all works should succeed"); + + // Verify sequential order: first work stops before second starts + const startIdx0 = callbacks.findIndex(c => c.callback === "workStarted" && c.workId === started[0]); + const stopIdx0 = callbacks.findIndex(c => c.callback === "workStopped" && c.workId === started[0]); + const startIdx1 = callbacks.findIndex(c => c.callback === "workStarted" && c.workId === started[1]); + assert.ok(stopIdx0 < startIdx1, "first work should stop before second starts (sequential)"); + }); +}); + +describe("Work: SequentialWork - first fails, second does not run", () => { + it("seq-fail-first-job fails and second task never starts", async () => { + const { callbacks } = await runJob("seq-fail-first-job"); + const failed = callbacks.some(c => c.callback === "jobFailed"); + assert.ok(failed, "job should fail"); + + const workIds = await getJobWorkIds("seq-fail-first-job"); + const secondWorkId = workIds[1].workId; + + const { started, stopped } = getWorkEvents(callbacks); + assert.strictEqual(started.length, 1, "only first work should start"); + assert.ok(!started.includes(secondWorkId), "second work should never start"); + assert.strictEqual(stopped.length, 1, "only one work stopped"); + assert.strictEqual(stopped[0].succeeded, false, "first work should fail"); + }); +}); + +describe("Work: SequentialWork - first succeeds, second fails", () => { + it("seq-fail-second-job fails at the second task", async () => { + const { callbacks } = await runJob("seq-fail-second-job"); + const failed = callbacks.some(c => c.callback === "jobFailed"); + assert.ok(failed, "job should fail"); + + const { started, stopped } = getWorkEvents(callbacks); + assert.strictEqual(started.length, 2, "both works should start"); + assert.strictEqual(stopped.length, 2, "both works should stop"); + + // First succeeded, second failed + assert.strictEqual(stopped[0].succeeded, true, "first work should succeed"); + assert.strictEqual(stopped[1].succeeded, false, "second work should fail"); + }); +}); + +// ============================================================ +// ParallelWork +// ============================================================ + +describe("Work: ParallelWork - all succeed", () => { + it("par-job succeeds with all parallel tasks", async () => { + const { callbacks } = await runJob("par-job"); + const succeeded = callbacks.some(c => c.callback === "jobSucceeded"); + assert.ok(succeeded, "job should succeed"); + + const { started, stopped } = getWorkEvents(callbacks); + assert.strictEqual(started.length, 2, "should have 2 workStarted"); + assert.strictEqual(stopped.length, 2, "should have 2 workStopped"); + assert.ok(stopped.every(s => s.succeeded), "all works should succeed"); + }); +}); + +describe("Work: ParallelWork - one fails, job fails, but all run", () => { + it("par-fail-one-job fails but both tasks run", async () => { + const { callbacks } = await runJob("par-fail-one-job"); + const failed = callbacks.some(c => c.callback === "jobFailed"); + assert.ok(failed, "job should fail when any parallel task fails"); + + const { started, stopped } = getWorkEvents(callbacks); + assert.strictEqual(started.length, 2, "both works should start (parallel)"); + assert.strictEqual(stopped.length, 2, "both works should stop (parallel waits for all)"); + + const succeededCount = stopped.filter(s => s.succeeded).length; + const failedCount = stopped.filter(s => !s.succeeded).length; + assert.strictEqual(succeededCount, 1, "one work should succeed"); + assert.strictEqual(failedCount, 1, "one work should fail"); + }); +}); + +// ============================================================ +// AltWork +// ============================================================ + +describe("Work: AltWork - condition succeeds, trueWork runs", () => { + it("alt-true-job: condition succeeds → trueWork triggers, job succeeds", async () => { + const workIds = await getJobWorkIds("alt-true-job"); + const condWorkId = workIds.find(w => w.taskId === "simple-task" && w.workId === workIds[0].workId).workId; + + const { callbacks } = await runJob("alt-true-job"); + const succeeded = callbacks.some(c => c.callback === "jobSucceeded"); + assert.ok(succeeded, "job should succeed"); + + const { started, stopped } = getWorkEvents(callbacks); + // condition (workId 0) + trueWork (workId 1) = 2 tasks started + assert.strictEqual(started.length, 2, "condition + trueWork should both start"); + assert.ok(stopped.every(s => s.succeeded), "all works should succeed"); + + // The second TaskWork that starts should be trueWork (workId 1) + const trueWorkId = workIds[1].workId; + assert.ok(started.includes(trueWorkId), "trueWork should be triggered"); + }); +}); + +describe("Work: AltWork - condition fails, falseWork runs", () => { + it("alt-false-job: condition fails → falseWork triggers, job succeeds", async () => { + const workIds = await getJobWorkIds("alt-false-job"); + + const { callbacks } = await runJob("alt-false-job"); + const succeeded = callbacks.some(c => c.callback === "jobSucceeded"); + assert.ok(succeeded, "job should succeed"); + + const { started, stopped } = getWorkEvents(callbacks); + // condition (workId 0, fails) + falseWork (workId 1, succeeds) = 2 started + assert.strictEqual(started.length, 2, "condition + falseWork should both start"); + + const falseWorkId = workIds[1].workId; + assert.ok(started.includes(falseWorkId), "falseWork should be triggered"); + + // Condition task fails, but falseWork succeeds + const condStopped = stopped.find(s => s.workId === workIds[0].workId); + assert.strictEqual(condStopped.succeeded, false, "condition task should fail"); + const falseStopped = stopped.find(s => s.workId === falseWorkId); + assert.strictEqual(falseStopped.succeeded, true, "falseWork should succeed"); + }); +}); + +describe("Work: AltWork - condition succeeds, trueWork undefined → succeeds", () => { + it("alt-true-undef-job: condition succeeds, no trueWork → job succeeds", async () => { + const workIds = await getJobWorkIds("alt-true-undef-job"); + + const { callbacks } = await runJob("alt-true-undef-job"); + const succeeded = callbacks.some(c => c.callback === "jobSucceeded"); + assert.ok(succeeded, "job should succeed when chosen work is undefined"); + + const { started } = getWorkEvents(callbacks); + // condition runs (workId 0), trueWork is undefined so falseWork (workId 1) should NOT run + assert.strictEqual(started.length, 1, "only condition should start, chosen branch is undefined"); + + const falseWorkId = workIds[1].workId; + assert.ok(!started.includes(falseWorkId), "falseWork should not be triggered when condition succeeds"); + }); +}); + +describe("Work: AltWork - condition fails, falseWork undefined → succeeds", () => { + it("alt-false-undef-job: condition fails, no falseWork → job succeeds", async () => { + const workIds = await getJobWorkIds("alt-false-undef-job"); + + const { callbacks } = await runJob("alt-false-undef-job"); + const succeeded = callbacks.some(c => c.callback === "jobSucceeded"); + assert.ok(succeeded, "job should succeed when chosen work is undefined"); + + const { started } = getWorkEvents(callbacks); + // condition runs (workId 0, fails), falseWork is undefined so trueWork (workId 1) should NOT run + assert.strictEqual(started.length, 1, "only condition should start, chosen branch is undefined"); + + const trueWorkId = workIds[1].workId; + assert.ok(!started.includes(trueWorkId), "trueWork should not be triggered when condition fails"); + }); +}); + +describe("Work: AltWork - condition succeeds, trueWork fails → job fails", () => { + it("alt-true-fail-job: condition succeeds, trueWork fails → job fails", async () => { + const workIds = await getJobWorkIds("alt-true-fail-job"); + + const { callbacks } = await runJob("alt-true-fail-job"); + const failed = callbacks.some(c => c.callback === "jobFailed"); + assert.ok(failed, "job should fail when chosen work fails"); + + const { started, stopped } = getWorkEvents(callbacks); + assert.strictEqual(started.length, 2, "condition + trueWork should both start"); + + const trueWorkId = workIds[1].workId; + assert.ok(started.includes(trueWorkId), "trueWork should be triggered"); + + const trueStopped = stopped.find(s => s.workId === trueWorkId); + assert.strictEqual(trueStopped.succeeded, false, "trueWork should fail"); + }); +}); + +describe("Work: AltWork - condition fails, falseWork fails → job fails", () => { + it("alt-false-fail-job: condition fails, falseWork fails → job fails", async () => { + const workIds = await getJobWorkIds("alt-false-fail-job"); + + const { callbacks } = await runJob("alt-false-fail-job"); + const failed = callbacks.some(c => c.callback === "jobFailed"); + assert.ok(failed, "job should fail when chosen work fails"); + + const { started, stopped } = getWorkEvents(callbacks); + assert.strictEqual(started.length, 2, "condition + falseWork should both start"); + + const falseWorkId = workIds[1].workId; + assert.ok(started.includes(falseWorkId), "falseWork should be triggered"); + + const falseStopped = stopped.find(s => s.workId === falseWorkId); + assert.strictEqual(falseStopped.succeeded, false, "falseWork should fail"); + }); +}); + +// ============================================================ +// LoopWork +// ============================================================ + +describe("Work: LoopWork - preCondition mismatch, body does not run", () => { + it("loop-pre-exit-job: preCondition expected true but fails → body skipped, job succeeds", async () => { + const workIds = await getJobWorkIds("loop-pre-exit-job"); + + const { callbacks } = await runJob("loop-pre-exit-job"); + const succeeded = callbacks.some(c => c.callback === "jobSucceeded"); + assert.ok(succeeded, "job should succeed when preCondition causes exit"); + + const { started } = getWorkEvents(callbacks); + // preCondition task (workId 0) runs; body task (workId 1) should NOT run + const bodyWorkId = workIds.find(w => w.workId !== workIds[0].workId).workId; + assert.ok(!started.includes(bodyWorkId), "body should not start when preCondition causes exit"); + + // Only condition task should have started + assert.strictEqual(started.length, 1, "only preCondition task should start"); + }); +}); + +describe("Work: LoopWork - preCondition matches, body runs once (no postCondition)", () => { + it("loop-pre-enter-job: preCondition expected true and succeeds → body runs, job succeeds", async () => { + const workIds = await getJobWorkIds("loop-pre-enter-job"); + + const { callbacks } = await runJob("loop-pre-enter-job"); + const succeeded = callbacks.some(c => c.callback === "jobSucceeded"); + assert.ok(succeeded, "job should succeed"); + + const { started } = getWorkEvents(callbacks); + // preCondition (workId 0) + body (workId 1) = 2 started + assert.strictEqual(started.length, 2, "preCondition + body should both start"); + assert.ok(started.includes(workIds[0].workId), "preCondition task should start"); + assert.ok(started.includes(workIds[1].workId), "body task should start"); + }); +}); + +describe("Work: LoopWork - body fails → loop fails", () => { + it("loop-body-fail-job: body fails → job fails", async () => { + const workIds = await getJobWorkIds("loop-body-fail-job"); + + const { callbacks } = await runJob("loop-body-fail-job"); + const failed = callbacks.some(c => c.callback === "jobFailed"); + assert.ok(failed, "job should fail when body fails"); + + const { started, stopped } = getWorkEvents(callbacks); + // preCondition (workId 0, succeeds) + body (workId 1, fails) = 2 + assert.strictEqual(started.length, 2, "preCondition + body should both start"); + + const bodyStopped = stopped.find(s => s.workId === workIds[1].workId); + assert.ok(bodyStopped, "body should have stopped"); + assert.strictEqual(bodyStopped.succeeded, false, "body should fail"); + }); +}); + +describe("Work: LoopWork - postCondition mismatch → exit after body", () => { + it("loop-post-exit-job: body runs, postCondition mismatch → job succeeds", async () => { + const workIds = await getJobWorkIds("loop-post-exit-job"); + + const { callbacks } = await runJob("loop-post-exit-job"); + const succeeded = callbacks.some(c => c.callback === "jobSucceeded"); + assert.ok(succeeded, "job should succeed when postCondition causes exit"); + + const { started, stopped } = getWorkEvents(callbacks); + // body (workId 0) + postCondition (workId 1, fails, expected true → mismatch → exit) + assert.strictEqual(started.length, 2, "body + postCondition should both start"); + + const bodyWorkId = workIds[0].workId; + const bodyStopped = stopped.find(s => s.workId === bodyWorkId); + assert.ok(bodyStopped, "body should have stopped"); + assert.strictEqual(bodyStopped.succeeded, true, "body should succeed"); + }); +}); diff --git a/.github/Agent/packages/CopilotPortal/tsconfig.json b/.github/Agent/packages/CopilotPortal/tsconfig.json new file mode 100644 index 00000000..ed464a96 --- /dev/null +++ b/.github/Agent/packages/CopilotPortal/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/.github/Agent/prompts/snapshot/CopilotPortal/API.md b/.github/Agent/prompts/snapshot/CopilotPortal/API.md new file mode 100644 index 00000000..022b7cb3 --- /dev/null +++ b/.github/Agent/prompts/snapshot/CopilotPortal/API.md @@ -0,0 +1,152 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `src/copilotSession.ts` +- `src/sharedApi.ts` +- `src/copilotApi.ts` +- `src/taskApi.ts` +- `src/jobsApi.ts` +- `src/index.ts` + +Data structures about jobs and tasks are in `src/jobsDef.ts`. +Its spec is in `JobsData.md`. + +## Starting the HTTP Server + +**Referenced by**: +- API_Task.md: `### copilot/test/installJobsEntry` + +- This package starts an http server, serving a website as well as a set of RESTful API. +- In src/index.ts it accepts command line options like this: + - `--port 8888`: Specify the http server port. `8888` by default. + - `--test`: `installJobsEntry` is not called, an extra API `copilot/test/installJobsEntry` is available only in this mode. +- Website entry is http://*:port +- API entry is http://*:port/api/... +- "yarn portal" to run src/index.ts. +- "yarn portal-for-test" to run src/index.ts in test mode + +It starts both Website and RESTful API. Awaits for api/stop to stop. +Prints the following URL for shortcut: +- http://localhost:port +- http://localhost:port/api/stop + +## Running the Website + +- http://*:port is equivalent to http://*:port/index.html. +- In the assets folder there stores all files for the website. +- Requesting for http://*:port/index.html returns assets/index.html. + +## Helpers (index.ts) -------------------------------------------------------------------------------------------------------------------------- + +### installJobsEntry + +`async installJobsEntry(entry: Entry): Promise;` +- Use the entry. It could be `entry` from `jobsData.ts` or whatever. +- This function can only be called when no session is running, otherwise throws. + +### ensureInstalledEntry + +**Referenced by**: +- API_Task.md: `### copilot/task/start/{task-name}/session/{session-id}` +- API_Job.md: `copilot/job/start/{job-name}` + +`ensureInstalledEntry(): Entry` +- Throw an error if `installJobsEntry` has not been called. +- Return the installed entry. + +## Helpers (copilotApi.ts) -------------------------------------------------------------------------------------------------------------------------- + +All helper functions and types are exported and API implementations should use them. + +### helperGetModels + +**Referenced by**: +- API.md: `### copilot/models` + +`async helperGetModels(): Promise` +- List all models. + +## API (copilotApi.ts) ------------------------------------------------------------------------------------------------------------------------------ + +All restful read arguments from the path and returns a JSON document. + +All title names below represents http:/*:port/api/TITLE + +Copilot hosting is implemented by `@github/copilot-sdk` and `src/copilotSession.ts`. + +### config + +**Referenced by**: +- Index.md: `#### Starting a Copilot Session` + +Returns the repo root path (detected by walking up from the server's directory until a `.git` folder is found). + +```typescript +{ + repoRoot: string; +} +``` + +### token + +Returns a random generated GUID everytime, no special meaning. +No need to store generated tokens. + +### stop + +**Referenced by**: +- Index.md: `#### Request Part` +- Jobs.md: `### Matrix Part` + +Stop any running sessions. +Returns `{}` and stops. + +### copilot/models + +**Referenced by**: +- Index.md: `#### Starting a Copilot Session` + +Returns all copilot sdk supported models in this schema + +```typescript +{ + models: { + name: string; + id: string; + multiplier: number; + }[] +} +``` + +## API (index.ts) ------------------------------------------------------------------------------------------------------------------------------ + +### test + +**Referenced by**: +- Test.md: `### test.html` + +Returns `{"message":"Hello, world!"}` + +### copilot/test/installJobsEntry + +Only available when `src/index.ts` is launched with `--test`. +The body will be an absolute path of a custom JSON file for entry. +The API will first check if the JSON file is in the `test` folder. +It reads the JSON file, called `validateEntry` followed by `installJobsEntry`. + +Return in this schema: +```typescript +{ + result: "OK" | "InvalidatePath" | "InvalidateEntry" | "Rejected", + error?: string +} +``` + +Return "InvalidatePath" when the file is not in the `test` folder. +Return "InvalidateEntry" when `validateEntry` throws. +Return "Rejected" when `installJobsEntry` throws. +"error" stores the exception message. diff --git a/.github/Agent/prompts/snapshot/CopilotPortal/API_Job.md b/.github/Agent/prompts/snapshot/CopilotPortal/API_Job.md new file mode 100644 index 00000000..5a10e241 --- /dev/null +++ b/.github/Agent/prompts/snapshot/CopilotPortal/API_Job.md @@ -0,0 +1,234 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `src/sharedApi.ts` +- `src/jobsApi.ts` +- `src/index.ts` + +Data structures about jobs and tasks are in `src/jobsDef.ts`. +Its spec is in `JobsData.md`. + +If an api requires `Entry`: +- The first argument of the api helper function must be `entry: Entry`. +- `index.ts` will call `ensureInstalledEntry` for this argument. + +**TEST-NOTE-BEGIN** +`yarn launch-for-test` will be used for unit testing therefore you have a chance to specify your own entry file. +DO NOT use the default entry for testing. + +The free model "gpt-5-mini" must be used in every `Entry.models`. +If the model is no longer available, choose a new one and update both spec and custom entry JSON files. +**TEST-NOTE-END** + +## Helpers (jobsApi.ts) ----------------------------------------------------------------------------------------------------------------------------- + +```typescript +interface ICopilotJob { + get runningWorkIds(): number[]; + get status(): "Executing" | "Succeeded" | "Failed"; + // stop all running tasks, no further callback issues. + stop(): void; +} + +interface ICopilotJobCallback { + // Called when this job succeeded + jobSucceeded(): void; + // Called when this job failed + jobFailed(): void; + // Called when this job failed + jobCanceled(): void; + // Called when a TaskWork started, taskId is the registered task for live polling + workStarted(workId: number, taskId: string): void; + // Called when a TaskWork stopped + workStopped(workId: number, succeeded: boolean): void; +} + +async function startJob( + entry: Entry, + jobName: string, + userInput: string, + workingDirectory: string, + callback: ICopilotJobCallback +): Promise +``` + +## API (jobsApi.ts) --------------------------------------------------------------------------------------------------------------------------------- + +All restful read arguments from the path and returns a JSON document. + +All title names below represents http:/*:port/api/TITLE + +Job hosting is implemented by and `src/jobsApi.ts`. + +### copilot/job + +**Referenced by**: +- Jobs.md: `### jobs.html`, `### Matrix Part` +- Jobs.md: `### jobTracking.html` + +List all jobs passed to `installJobsEntry` in this schema: +```typescript +{ + grid: GridRow[]; + jobs: { [key in string]: Job }; + chart: { [key in string]: ChartGraph }; +} +``` + +Basically means it only keeps `grid` and `jobs` and drops all other fields, and calculate `chart` with `generateChartNodes`. + +### copilot/job/running + +List all jobs that: +- Running +- Finished less than an hour + +in this schema: + +```typescript +{ + jobs: { + jobId: string; + jobName: string; + startTime: Date; + status: "Running" | "Succeeded" | "Failed" | "Canceled"; + }[]; +} +``` + +### copilot/job/{job-id}/status + +If a job is running or finished less than an hour, return in this schema: + +```typescript +{ + jobId: string; + jobName: string; + startTime: Date; + status: "Running" | "Succeeded" | "Failed" | "Canceled"; + tasks: { + workIdInJob: number; + taskId?: string; // available only when running + status: "Running" | "Succeeded" | "Failed"; + }[]; +} +``` + +otherwise: + +```typescript +{ + error: "JobNotFound" +} +``` + +### copilot/job/start/{job-name} + +**Referenced by**: +- Jobs.md: `#### Clicking Start Job Button` + +**TEST-NOTE-BEGIN** +Besides of testing API failures, it is important to make sure job running works. + +Create test cases for running a job, focused on different types of `Work`s. +Test every kinds of `Work` and ensure: +- It succeeds when all involved tasks succeed. Should test against every type of `Work`. +- If fails properly. Should test against every type of `Work`, in each possible failure position. +- Whenever the job succeeded or failed, the live api responses correctly. +- Execution of tasks in `Work` should be observable from the live api. + +Skip testing against task crashes scenarios because it is difficult to make it crash. +**TEST-NOTE-END** + +The first line will be an absolute path for working directory +The rest of the body will be user input. + +Start a new job and return in this schema. +The job remembers its `job-name` and start time. +It also remembers status of all tasks. When a task restarts, status overrides. + +```typescript +{ + jobId: string; +} +``` + +or when error happens: + +```typescript +{ + error: "JobNotFound" +} +``` + +### copilot/job/{job-id}/stop + +**Referenced by**: +- Jobs.md: `### Job Part` + +A job will automatically stops when finishes, +this api forced the job to stop. + +Stop the job and return in this schema. + +```typescript +{ + result: "Closed" +} +``` + +or when error happens: + +```typescript +{ + error: "JobNotFound" +} +``` + +### copilot/job/{job-id}/live/{token} + +**Referenced by**: +- Jobs.md: `### Job Part`, `### Session Response Part`, `### jobTracking.html` + +It works like `copilot/session/{session-id}/live/{token}` but it reacts to `ICopilotJobCallback`. +They should be implemented in the same way, but only respond in schemas mentioned below. +`token` can be obtained by `/token` but no checking needs to perform. + +Returns in this schema when responses are available (batched, same as session live API): + +```typescript +{ + responses: LiveResponse[] +} +``` + +Returns in this schema if any error happens + +```typescript +{ + error: "JobNotFound" | "JobsClosed" | "HttpRequestTimeout" | "ParallelCallNotSupported" +} +``` + +Special `JobNotFound` and `JobsClosed` handling for this API: +- Works in the same way as `SessionNotFound` and `SessionClosed` in `copilot/session/{session-id}/live/{token}`. + +**TEST-NOTE-BEGIN** +Can't trigger "HttpRequestTimeout" stably in unit test so it is not covered. +It requires the underlying copilot agent to not generate any response for 5 seconds, +which is almost impossible. +**TEST-NOTE-END** + +An element in the `responses` array with this schema represents an exception thrown from inside the session + +```typescript +{ + jobError: string +} +``` + +Each element in the `responses` array maps to a method in `ICopilotJobCallback` in `src/jobsApi.ts`. diff --git a/.github/Agent/prompts/snapshot/CopilotPortal/API_Session.md b/.github/Agent/prompts/snapshot/CopilotPortal/API_Session.md new file mode 100644 index 00000000..541c8899 --- /dev/null +++ b/.github/Agent/prompts/snapshot/CopilotPortal/API_Session.md @@ -0,0 +1,331 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `src/copilotSession.ts` +- `src/sharedApi.ts` +- `src/copilotApi.ts` +- `src/index.ts` + +Data structures about jobs and tasks are in `src/jobsDef.ts`. +Its spec is in `JobsData.md`. + +If an api requires `Entry`: +- The first argument of the api helper function must be `entry: Entry`. +- `index.ts` will call `ensureInstalledEntry` for this argument. + +**TEST-NOTE-BEGIN** +`yarn launch-for-test` will be used for unit testing therefore you have a chance to specify your own entry file. +DO NOT use the default entry for testing. + +The free model "gpt-5-mini" must be used in every `Entry.models`. +If the model is no longer available, choose a new one and update both spec and custom entry JSON files. +**TEST-NOTE-END** + +## Helpers (copilotApi.ts) -------------------------------------------------------------------------------------------------------------------------- + +All helper functions and types are exported and API implementations should use them. + +### helperSessionStart + +**Referenced by**: +- API_Session.md: `### copilot/session/start/{model-id}` + +`async helperSessionStart(modelId: string, workingDirectory?: string): Promise<[ICopilotSession, string]>;` +- Start a session, return the session object and its id. + +### helperSessionStop + +**Referenced by**: +- API_Session.md: `### copilot/session/{session-id}/stop` +- API.md: `### stop` + +`async helperSessionStop(session: ICopilotSession): Promise;` +- Stop a session. + +### helperGetSession + +**Referenced by**: +- API_Session.md: `### copilot/session/{session-id}/stop`, `### copilot/session/{session-id}/query`, `### copilot/session/{session-id}/live/{token}` + +`helperGetSession(sessionId: string): ICopilotSession | undefined;` +- Get a session by its id. + +### helperPushSessionResponse + +**Referenced by**: +- API_Session.md: `### copilot/session/{session-id}/live/{token}` + +`helperPushSessionResponse(session: ICopilotSession, response: LiveResponse): void;` +- Push a response to a session's response queue. + +### hasRunningSessions + +**Referenced by**: +- API.md: `### stop` + +`hasRunningSessions(): boolean;` +- Returns true if any sessions are currently running. + +## Helpers (copilotSession.ts) ---------------------------------------------------------------------------------------------------------------------- + +Wraps `@github/copilot-sdk` to provide a simplified session interface with event callbacks. + +### ICopilotSession + +**Referenced by**: +- API_Session.md: `### helperSessionStart`, `### helperSessionStop`, `### helperGetSession`, `### helperPushSessionResponse`, `### startSession` + +```typescript +interface ICopilotSession { + get rawSection(): CopilotSession; + sendRequest(message: string, timeout?: number): Promise; + stop(): Promise +} +``` +- Interface for interacting with a Copilot session. +- When a session is no longer using, `stop` must be called. `CopilotSession.destroy` should be called by `stop`. + +### ICopilotSessionCallbacks + +**Referenced by**: +- API_Session.md: `### copilot/session/{session-id}/live/{token}`, `### startSession` +- Index.md: `#### Session Interaction`, `#### Request Part` +- Shared.md: `#### Session Response Rendering` + +```typescript +interface ICopilotSessionCallbacks { + onStartReasoning(reasoningId: string): void; + onReasoning(reasoningId: string, delta: string): void; + onEndReasoning(reasoningId: string, completeContent: string): void; + onStartMessage(messageId: string): void; + onMessage(messageId: string, delta: string): void; + onEndMessage(messageId: string, completeContent: string): void; + onStartToolExecution(toolCallId: string, parentToolCallId: string | undefined, toolName: string, toolArguments: string): void; + onToolExecution(toolCallId: string, delta: string): void; + onEndToolExecution(toolCallId: string, result: { content: string; detailedContent?: string } | undefined, error: { message: string; code?: string } | undefined): void; + onAgentStart(turnId: string): void; + onAgentEnd(turnId: string): void; + onIdle(): void; +} +``` +- Callback interface for all session events. + +### startSession + +**Referenced by**: +- API_Session.md: `### copilot/session/start/{model-id}` + +`async startSession(client: CopilotClient, modelId: string, callback: ICopilotSessionCallbacks, workingDirectory?: string): Promise;` +- Create a session with the given model, register job tools, wire up all event handlers, and return an `ICopilotSession`. + +## API (copilotApi.ts) ------------------------------------------------------------------------------------------------------------------------------ + +All restful read arguments from the path and returns a JSON document. + +All title names below represents http://*:port/api/TITLE + +Copilot hosting is implemented by `@github/copilot-sdk` and `src/copilotSession.ts`. + +### copilot/session/start/{model-id} + +**Referenced by**: +- Index.md: `##### Start Button` + +The body will be an absolute path for working directory + +Start a new copilot session and return in this schema + +```typescript +{ + sessionId: string; +} +``` + +or when error happens: + +```typescript +{ + error: "ModelIdNotFound" | "WorkingDirectoryNotAbsolutePath" | "WorkingDirectoryNotExists" +} +``` + +Multiple sessions could be running parallelly, start a `CopilotClient` if it is not started yet, it shares between all sessions. +The `CopilotClient` will be closed when the server is shutting down. + +### copilot/session/{session-id}/stop + +**Referenced by**: +- Index.md: `#### Request Part` + +Stop the session and return in this schema + +```typescript +{result:"Closed"} | {error:"SessionNotFound"} +``` + +### copilot/session/{session-id}/query + +**Referenced by**: +- Index.md: `#### Request Part` + +The body will be the query prompt string. + +Send the query to the session, and the session begins to work. + +Returns in this schema + +```typescript +{ + error?:"SessionNotFound" +} +``` + +### copilot/session/{session-id}/live/{token} + +**Referenced by**: +- Index.md: `#### Session Interaction` +- Shared.md: `#### Session Response Rendering` +- API_Task.md: `### copilot/task/{task-id}/live/{token}` +- API_Job.md: `### copilot/job/{job-id}/live/{token}` +- Jobs.md: `### jobTracking.html` + +This is a query to wait for responses back for this session. +Each session generates many responses, storing in a list. +When the api comes, the current reading position needs to look up for the `token`, if `token` is not used with this `session-id`, create a record and put 0. + +Each query returns **all** available responses from the current position to the end of the list in a single batch, and the position advances past all returned responses. +This ensures that when multiple tokens poll the same entity, they drain buffered responses instantly and converge to the same pending position, so all tokens receive new responses simultaneously. + +If there is no response, do not reply the API. If there is no response after 5 seconds, send back a `HttpRequestTimeout`. +Be aware of that api requests and session responses could happen in any order. + +This api does not support parallel calling on the (`session-id`, `token`). +If a call with a (`session-id`, `token`) is pending, +the second call with the same (`session-id`, `token`) should return `ParallelCallNotSupported`. + +#### Optimization + +When `onEndReasinong` is pushed to the responses list: +- Focus on the same id, do not touch others. +- Search from the end, stop at the `onStartReasoning`. +- Remove all `onReasoning` for that id. +- This would affect cached token positions. It is easy to fix by counting how many removed responses has been read. + +When `onReasoning` is pushed to the responses list: +- Focus on the same id, do not touch others. +- Search from the end, stop at the `onStartReasoning`. +- For all `onReasoning` whose position >= `largest token reading position`, merge them to one. Concat all `delta` together without delimiter. +- For all `onReasoning` whose position < `smallest token reading position`, merge them to one. Concat all `delta` together without delimiter. + - This would affect cached token positions. It is easy to fix by counting how many removed responses has been read. + +Applies above optimization to `onStartMessage`, `onMessage`, `onEndMessage`. + +When a new response pushes to the responses list, causing a pending request to wake up: +- The time of the last live api responding (not the requesting) needs to be recorded for the `token` and uses it as below. +- Now the pending request will not return `HttpRequestTimeout`, but it will wait until 5 seconds after the last call. + - If it is already longer than 5 seconds, send new responses immediately. + - Whenever a live api request is responded, update the time for that `token`. + +#### API Schema + +Returns in this schema when responses are available: + +```typescript +{ + responses: LiveResponse[] +} +``` + +Returns in this schema if any error happens + +```typescript +{ + error: "SessionNotFound" | "SessionClosed" | "HttpRequestTimeout" | "ParallelCallNotSupported" +} +``` + +**TEST-NOTE-BEGIN** +Can't trigger "HttpRequestTimeout" stably in unit test so it is not covered. +It requires the underlying copilot agent to not generate any response for 5 seconds, +which is almost impossible. +If the server is launched with `--test` to enter a test mode, the 1 minute count down becomes 5 seconds, that enable you to test the logic efficiently. +**TEST-NOTE-END** + +#### Session Life-cycle and Visibility + +When the session is running, its life-cycle remains. +When the session is finished, it counts down for 1 minute. +When count down ends, the life-cycle ends. + +If a `token` is used for the session during its life-cycle, the session will be forever visible to the token, until all responses are drained: +- The `token` will be able to access all responses generated by this session in its life-cycle. + - The position 0 means the first response after the session starts, not the first response after a new token joined. +- When a session is closed, but responses for this session is not drained by the API yet, the API still responses. +- When there is no more response and the session already stopped, it returns `SessionClosed`. +- After `SessionClosed`, the `session-id` will be no longer available for this api, future calls returns `SessionNotFound`. +- Even the client received notification that a session stops from another API, it is recommended to keep reading until `SessionClosed` returns. + +If the `session-id` does not exist, or a `token` is used after the session's life-cycle: +- the session is not visible and it should return `SessionNotFound`. + +#### Session Response Storages + +Tips for implementation: +- All responses will be adding to a list. +- A map from token to one reading position and one pending api calls is maintained. +- The 1 minute counting down do not need to involve a timer, it could be implemented by: + - Before the session ends, store `undefined` to the `count down begin`. + - When a new `token` comes: + - If `count down begin` is `undefined`, or it is defined but still in 1 minute, add this token to the map. + - Otherwise this session is not visible to the token, reject the call with `SessionNotFound`. + - After replying `SessionClosed` for a token, the token is removed from the map. + - After the map is empty, the session can be deleted. + - `ICopilotSession.stop` should be called when the session is no longer working. + - `session can be deleted` here only means the data structure maintained for token accessing. + +**TODO**: If a token is used but the client suddenly ends, since `SessionClosed` can never be sent to this client, memory leaks happens. But let us ignore this issue yet, it will be addressed in the future. + +#### Exception Handling + +An element in the `responses` array with this schema represents an exception thrown from inside the session + +```typescript +{ + sessionError: string +} +``` + +#### Response Format + +Each element in the `responses` array maps to a method in `ICopilotSessionCallbacks` in `src/copilotSession.ts` in this schema + +```typescript +{ + callback: string, + argument1: ..., + ... +} +``` + +For example, when `onReasoning(reasoningId: string, delta: string): void;` is called, it returns + +```typescript +{ + callback: "onReasoning", + reasoningId: string, + delta: string +} +``` + +When running a task, any generated prompts will be reported in this schema + +```typescript +{ + callback: "onGeneratedUserPrompt", + prompt: string +} +``` diff --git a/.github/Agent/prompts/snapshot/CopilotPortal/API_Task.md b/.github/Agent/prompts/snapshot/CopilotPortal/API_Task.md new file mode 100644 index 00000000..51659ca6 --- /dev/null +++ b/.github/Agent/prompts/snapshot/CopilotPortal/API_Task.md @@ -0,0 +1,239 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `src/sharedApi.ts` +- `src/taskApi.ts` +- `src/index.ts` + +Data structures about jobs and tasks are in `src/jobsDef.ts`. +Its spec is in `JobsData.md`. + +If an api requires `Entry`: +- The first argument of the api helper function must be `entry: Entry`. +- `index.ts` will call `ensureInstalledEntry` for this argument. + +**TEST-NOTE-BEGIN** +`yarn launch-for-test` will be used for unit testing therefore you have a chance to specify your own entry file. +DO NOT use the default entry for testing. + +The free model "gpt-5-mini" must be used in every `Entry.models`. +If the model is no longer available, choose a new one and update both spec and custom entry JSON files. +**TEST-NOTE-END** + +## Helpers (taskApi.ts) ----------------------------------------------------------------------------------------------------------------------------- + +All helper functions and types are exported and API implementations should use them. + +### SESSION_CRASH_PREFIX + +**Referenced by**: +- JobsData.md: `## Running Tasks` + +`const SESSION_CRASH_PREFIX = "The session crashed, please redo and here is the last request:\n";` +- Internal constant (not exported). Prefix added to prompts when resending after a session crash. + +### sendMonitoredPrompt (crash retry) + +**Referenced by**: +- API_Task.md: `### copilot/task/start/{task-name}/session/{session-id}`, `### copilot/task/{task-id}/stop`, `### copilot/task/{task-id}/live/{token}` +- API_Job.md: `### copilot/job/start/{job-name}`, `### copilot/job/{job-id}/stop`, `### copilot/job/{job-id}/live/{token}` +- JobsData.md: `## Running Tasks`, `## Running Jobs`, `### Task.availability`, `### Task.criteria` + +A private method on the `CopilotTaskImpl` class used by both task execution and condition evaluation. +Sends a prompt to a session, retrying up to 5 consecutive crashes (except in borrowing mode where retry is 1). +On retry, prepends `SESSION_CRASH_PREFIX` to the prompt. +Also pushes `onGeneratedUserPrompt` to the driving session's response queue. + +### errorToDetailedString + +`errorToDetailedString(err: unknown): string;` +- Convert any error into a detailed JSON string representation including name, message, stack, and cause. + +```typescript +interface ICopilotTask { + readonly drivingSession: ICopilotSession; + readonly status: "Executing" | "Succeeded" | "Failed"; + readonly crashError?: any; + // stop all running sessions, no further callback issues. + stop(): void; +} + +interface ICopilotTaskCallback { + // Called when this task succeeded + taskSucceeded(): void; + // Called when this task failed + taskFailed(): void; + // Called when the driving session finishes a test or makes a decision + taskDecision(reason: string): void; + // This callback is unavailable if it is running with borrowing session mode + // Called when a task session started, with its session id + // When this session is created as a driving session, the isDrivingSession argument is true. + taskSessionStarted(taskSession: ICopilotSession, taskId: string, isDrivingSession: boolean): void; + // This callback is unavailable if it is running with borrowing session mode + // Called when a task session stopped, with its session id + // If the task succeeded, the succeeded argument is true + taskSessionStopped(taskSession: ICopilotSession, taskId: string, succeeded: boolean): void; +} + +async function startTask( + entry: Entry, + taskName: string, + userInput: string, + drivingSession: ICopilotSession | undefined, + ignorePrerequisiteCheck: boolean, + callback: ICopilotTaskCallback, + taskModelIdOverride: string | undefined, + workingDirectory: string | undefined, + exceptionHandler: (err: any) => void +): Promise +``` +- Start a task. +- When `drivingSession` is defined: + - the task is in borrowing session mode +- When `drivingSession` is not defined: + - `startTask` needs to create and maintain all sessions it needs. +- The `exceptionHandler` is called if the task execution throws an unhandled exception. + +## API (taskApi.ts) --------------------------------------------------------------------------------------------------------------------------------- + +All restful read arguments from the path and returns a JSON document. + +All title names below represents http:/*:port/api/TITLE + +Task hosting is implemented by `src/taskApi.ts`. + +### copilot/task + +**Referenced by**: +- Index.md: `#### Request Part` + +List all tasks passed to `installJobsEntry` in this schema: +```typescript +{ + tasks: { + name: string; + requireUserInput: boolean; + }[] +} +``` + +### copilot/task/start/{task-name}/session/{session-id} + +**Referenced by**: +- Index.md: `#### Request Part` + +**TEST-NOTE-BEGIN** +Besides of testing API failures, it is important to make sure task running works. + +Create test cases for running a task, focused on the `criteria` stuff. +Test every fields in `criteria` and ensure: +- If the test succeeded, retry won't happen. +- If the task failed, retry happens. +- Retry has a budget limit. +- Whenever the task succeeded or failed, the live api responses correctly. +- Retrying should be observable from the live api. + +Skip testing against session crashes scenarios because it is difficult to make it crash. + +It is able to make up a failed test by: +- Does nothing +- In `criteria` specify `toolExecuted`, since the task does nothing, this will never satisfies. +- Set a retry budget limit to 0 so no retrying should happen. +- Therefore it fails because of not being able to pass the criteria check. +**TEST-NOTE-END** + +The body will be user input. + +Start a new task and return in this schema. +borrowing session mode is forced with an existing session id. +Prerequisite checking is skipped. + +After the task finishes, it stops automatically, the task id will be unavailable immediately. +Keep the session alive. + +```typescript +{ + taskId: string; +} +``` + +or when error happens: + +```typescript +{ + error: "SessionNotFound" +} +``` + +### copilot/task/{task-id}/stop + +The API will ignore the action and return `TaskCannotClose` if the task is started with borrowing session mode. + +A task will automatically stops when finishes, +this api forced the task to stop. + +Stop the task and return in this schema. + +```typescript +{ + result: "Closed" +} +``` + +or when error happens: + +```typescript +{ + error: "TaskNotFound" | "TaskCannotClose" +} +``` + +### copilot/task/{task-id}/live/{token} + +**Referenced by**: +- Jobs.md: `### jobTracking.html` + +It works like `copilot/session/{session-id}/live/{token}` but it reacts to `ICopilotTaskCallback`. +They should be implemented in the same way, but only respond in schemas mentioned below. +`token` can be obtained by `/token` but no checking needs to perform. + +Returns in this schema when responses are available (batched, same as session live API): + +```typescript +{ + responses: LiveResponse[] +} +``` + +Returns in this schema if any error happens + +```typescript +{ + error: "TaskNotFound" | "TaskClosed" | "HttpRequestTimeout" | "ParallelCallNotSupported" +} +``` + +Special `TaskNotFound` and `TaskClosed` handling for this API: +- Works in the same way as `SessionNotFound` and `SessionClosed` in `copilot/session/{session-id}/live/{token}`. + +**TEST-NOTE-BEGIN** +Can't trigger "HttpRequestTimeout" stably in unit test so it is not covered. +It requires the underlying copilot agent to not generate any response for 5 seconds, +which is almost impossible. +**TEST-NOTE-END** + +An element in the `responses` array with this schema represents an exception thrown from inside the session + +```typescript +{ + taskError: string +} +``` + +Each element in the `responses` array maps to a method in `ICopilotTaskCallback` in `src/taskApi.ts`. + +When a task is created by a job's `executeWork`, the `taskSessionStarted` response additionally includes `sessionId` (string) and `isDriving` (boolean) fields so the frontend can poll `copilot/session/{session-id}/live/{token}` and distinguish between driving and task sessions. diff --git a/.github/Agent/prompts/snapshot/CopilotPortal/Index.md b/.github/Agent/prompts/snapshot/CopilotPortal/Index.md new file mode 100644 index 00000000..7ea7659d --- /dev/null +++ b/.github/Agent/prompts/snapshot/CopilotPortal/Index.md @@ -0,0 +1,124 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `assets` + - `index.css` + - `index.js` + - `index.html` + +### index.css + +Put index.html specific css file in index.css. + +### index.js + +Put index.html specific javascript file in index.js. + +### index.html + +#### Starting a Copilot Session + +**Referenced by**: +- Jobs.md: `### jobs.html` + +When the webpage is loaded, it renders a UI in the middle to let me input: +- Model. A combo box with contents retrieved from `api/copilot/models`. + - Items must be sorted. + - Names instead of ids are displayed, but be aware of that other API needs the id. + - Default to the model whose id is "gpt-5.2" +- Working Directory. A text box receiving an absolute full path. + - When the url is `index.html?project=XXX`, the text box defaults to `REPO-ROOT\..\XXX` + - When there is no `project` argument, it becomes `REPO-ROOT`. + - `REPO-ROOT` is the root folder of the repo that the source code of this project is in (no hardcoding). + +There are a row of buttons with proper margin: +- On the very left: "New Job", "Refresh" +- On the very right: "Start" + +Below there is a list, displaying all running job's name, status, time. +The list only refresh when the webpage is loaded or the "Refresh" button is clicked. +It can be listed by `copilot/job/running`. +At the very left of each item, there is a "View" button. It starts `/jobTracking.html` to inspect into the job. + +The list must be in the same width with the above part. + +##### Start Button + +When I hit the "Start" button, the UI above disappears and show the session UI. +Send `api/copilot/session/start/{model-id}` to get the session id. + +Only after the model list is loaded, "Start" is enabled. + +##### Jobs Button + +**Referenced by**: +- Jobs.md: `### jobs.html` + +When I hit the "New Job" button, it jumps to `/jobs.html`. +The selected model is ignored, but the working directory should be brought to `/jobs.html`. + +#### Session Interaction + +The agent UI has two part. + +The upper part (session part) renders what the session replies. +The lower part (request part) renders a text box to send my request to the session. +The session part and the request part should always fill the whole webpage. +Between two parts there is a bar to drag vertically to adjust the height of the request part which defaults to 300px. + +After the UI is loaded, +call `api/token` for a token, +the page must keep sending `api/copilot/session/{session-id}/live/{token}` to the server sequentially (aka not parallelly). +When a timeout happens, resend the api. +When it returns any response, process it and still keep sending the api. +Whenever `ICopilotSessionCallbacks::METHOD` is mentioned, it means a response from this api. + +There are additional callbacks: +- `onGeneratedUserPrompt`: Create a "User" message block with `title` set to `Task`, pretending that the user is talking. + +After "Stop Server" or "Close Session" is pressed, responses from this api will be ignored and no more such api is sending. + +#### Session Part + +The session part div is passed to a `SessionResponseRenderer` (from `sessionResponse.js`) which handles all rendering within it. +See `Shared.md` for `SessionResponseRenderer` specification. + +Live polling callbacks are forwarded to `sessionRenderer.processCallback(data)`. +When its return value is `onIdle`, the send button is re-enabled. + +When not running a task, any user request should call `sessionRenderer.addUserMessage(text)` to create a "User" message block. +Call `sessionRenderer.setAwaiting(true)` when waiting for responses, and `sessionRenderer.setAwaiting(false)` when done (sync with "Send" button's enabled state). + +#### Request Part + +**Referenced by**: +- Jobs.md: `### Matrix Part` + +At the very top there is a label "Choose a Task: " followed by a combo box in a proper weight, listing all tasks. +Keep the visual looks similar and good margin between controls. +The first item is "(none)". When it is selected, "Send" will talk to the session directly instead of starting a task. +If a task is selected, the same session will be reused to start a task. + +It is a multiline text box. I can type any text, and press CTRL+ENTER to send the request. + +There is a "Send" button at the right bottom corner of the text box. +It does the same thing as pressing CTRL+ENTER. +When the button is disabled, pressing CTRL+ENTER should also does nothing. +`api/copilot/session/{session-id}/query` is used here. + +User request should generate a "User" message block, append the request and immediately complete it. + +When a request is sent, the button is disabled. +When `ICopilotSessionCallbacks::onIdle` triggers, it is enabled again. + +There is a "Stop Server" and "Close Session" button (in the mentioning order) at the left bottom corner of the text box. +When "Close Session" is clicked: +- Ends the session with `api/copilot/session/{session-id}/stop`. +- Do whatever needs for finishing. +- Try to close the current webpage window or tab. If the browser blocks it (e.g. Chrome blocks `window.close()` for tabs not opened by script), replace the page content with a message indicating the session has ended and the tab may be closed manually. +When "Stop Server" is clicked: +- It does what "Close Session" does, with an extra `api/stop` to stop the server before attempting to close the webpage. diff --git a/.github/Agent/prompts/snapshot/CopilotPortal/Jobs.md b/.github/Agent/prompts/snapshot/CopilotPortal/Jobs.md new file mode 100644 index 00000000..e375adda --- /dev/null +++ b/.github/Agent/prompts/snapshot/CopilotPortal/Jobs.md @@ -0,0 +1,254 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `assets` + - `jobs.css` + - `jobs.js` + - `jobs.html` + - `jobTracking.css` + - `jobTracking.js` + - `jobTracking.html` + - `flowChartMermaid.js` + +### jobs.css + +Put jobs.html specific css file in jobs.css. + +### jobs.js + +Put jobs.html specific javascript file in jobs.js. + +### jobs.html + +This page should only be opened by `/index.html`'s "Jobs" button in order to obtain the working directory. +If it is directly loaded, it should redirect itself to `/index.html`. +To tell this, find the `wb` argument in the url passing from `/index.html`. + +Call `api/copilot/job` to obtain jobs definition. +- `grid` defines the `matrix part` of the UI. +- `jobs` offers details for each job in order to render the tracking UI. + +The webpage is split into two parts: +- The left part is `matrix part`. +- The right part is `user input part`. +- The left and right parts should always fill the whole webpage. +- Between two parts there is a bar to drag vertically to adjust the width of the right part which defaults to 800. + +The look-and-feel must be similar to `/index.html`, but DO NOT share any css file. + +### Matrix Part + +It renders a large table of buttons according to `grid`. +The first row is a title "Available Jobs", followed by a button "Stop Server" at the very right doing exactly what the button in `/index.html` does. +The first column shows `keyword`. +All other columns are for `grid[index].jobs`, `name` will be the text of the button. +`undefined` in any `grid[index].jobs` element renders an empty cell. + +If any cell has no button, leave it blank. +The table is supposed to fill all `matrix part` but leave margins to the border and between buttons. +The table is also rendered with light color lines. + +Font size should be large enough to reduce blanks, prepare to fill about 1000x1000 or a even larger space. The complete content can be read in `jobsData.ts`, it helps to guess a font size as it will but rarely changes. + +Besides automate buttons, other buttons renders as buttons but it works like radio buttons: +- Clicking a job button renders an exclusive selected state. + - Its `jobName` (not the `name` in the button text) becomes `selectedJobName`. + - The "Start Job" button is enabled. + - Only when `requireUserInput` is true, the text box is enabled +- Clicking a selected job button unselect it + - The "Start Job" button is disabled. + - The text box is disabled. + +#### Actions of Automate Button + +(to be editing...) + +### User Input Part + +There is a text box fills the page. Disabled by default. + +At the bottom there are buttons aligned to the right: +- "Start Job: ${selectedJobName}" or "Job Not Selected". Disabled by default. +- "Preview". + +#### Clicking Start Job Button + +When I hit the "Start Job" button, call `copilot/job/start/{job-name}`. +When a job id is returned, it jumps to `/jobTracking.html` in a new window. +The selected job directory and the job id should be brought to `/jobTracking.html`. +No need to bring other information. + +#### Clicking Preview Button + +Sync "Preview"'s enability to "Start Job". +Call `/jobTracking.html` without starting the job and not passing the job id. +This triggers the preview mode. + +(to be editing...) + +### jobTracking.css + +Put jobTracking.html specific css file in jobTracking.css. + +### jobTracking.js + +Put jobTracking.html specific javascript file in jobTracking.js. + +### jobTracking.html + +This page should only be opened by `/jobs.html`'s "Start Job" button in order to obtain the working directory. +If it is directly loaded, it should redirect itself to `/index.html`. +To tell this, find the `jobName` and `jobId` argument in the url passing from `/jobs.html`. + +Call `api/copilot/job` to obtain the specified job's definition. +`jobs[jobName]` and `chart[jobName]` is used to render the flow chart. + +#### When jobId argument presents + +Call `api/copilot/job/{job-id}/live/{token}`, `api/copilot/task/{task-id}/live/{token}` and `copilot/session/{session-id}/live/{token}` to update the running status of the job: +- job live api notifies when a new task is started, task live api notifies when a new session is started. +- Drain all responses from live apis, no more issue until `(Session|Task|Job)(Closed|NotFound)` returns. +- On receiving `ICopilotTaskCallback.taskDecision` + - Create a "User" message block with `title` set to `TaskDecision`, copying the `reason`, put it in the driving session. + +#### When jobId argument not presents + +The webpage preview the job only, no tracking is performed. +The "Stop Job" button disappears. +The job status becomes "JOB: PREVIEW". +Clicking `ChartNode` does nothing. + +#### Layout + +The webpage is split into two parts: +- The left part is `job part`. +- The right part is `session response part`. +- The left and right parts should always fill the whole webpage. +- Between two parts there is a bar to drag vertically to adjust the width of the right part which defaults to 800. + +The look-and-feel must be similar to `/index.html`, but DO NOT share any css file. + +### Job Part + +You can find the `Job` definition in `jobsDef.ts`. +`Job.work` is a simple control flow AST. +Render it like a flow chart expanding to fill the whole `job part`. + +#### Flow Chart Rendering Note + +#### Tracking Job Status + +When the webpage is loaded, call `api/copilot/job/{job-id}/status` and update job status and each `ChartNode`. +The job could have been running for a long time. But the job live api will still response all history. +When a task or session of notified started, it could already have been stopped, ignore the error when task/session api says the entity does not exist. + +Call `api/token` for a token, and it can be used in any live api call issued in thie webpage. +When the status of a task running is changed, +update the rendering of a `ChartNode` of which `hint` is `TaskNode` or `CondNode`: +- If a symbol attaches to a node, it is at the center of the left border but outside. + - Use emoji if possible. + - If it is technically impossible to touch the left border you can choose another place. +- When a task begins running, attach a green triangle. +- When a task finishes running, the triangle is removed. + - If it succeeds, a green tick replaces the triangle. + - If it fails, a red cross replaces the triangle. +- There could be loops in the flow chart, which means a task could starts and stops multiple times. + +When a task runs, track all status of sessions in this task: +- The driving session names `Driving`. +- Multiple occurrences of the task session names beginning from `Attempt #1`. + - If a task is being inspected, when a session starts, `session response part` should display the new tab header, but do not switch to the new tab. + - Responses in a tab should keep updating if new data comes. +- Whenever the task is being inspected or not, status should keep recording. The user will inspect here and there, data should persist. + - For convenience, whenever a session starts, a `Session Response Rendering` from `Shared.md` could be created and adding message blocks. + - When the task is being inspected, display it, otherwise removes it from the DOM. +- When a task begins **AGAIN**, cached data will be deleted and replaced with status of the latest run. + +The `Flow Chart Rendering` should keep centered horizontally and vertically. +At the very top of the `job part`: +- At the left there is a big label showing the job that: + - `JOB: RUNNING` + - `JOB: SUCCEEDED` + - `JOB: FAILED` +- At the right there is a button in the same label size "Stop Job" + - It is enabled when the job is running. + - Clicking the button should call `api/copilot/job/{job-id}/stop` and the big label becomes `JOB: CANCELED`. + +#### Flow Chart Rendering + +**TEST-NOTE-BEGIN** +No need to create unit test to assert the chart is in a correct layout. +Ensure every `TaskWork` has a `ChartNode` with `TaskNode` or `CondNode` hint. +**TEST-NOTE-END** + +The `api/copilot/job` has an extra `chart` node which is a `ChartGraph`. +It is already a generated flow chart but has no layout information. + +Each `ChartNode` is a node in the flow chart, and each hint maps to a graph: +- `TaskNode`: A blue rectangle with the task id, the text would be the `TaskWork` with that `workIdInJob`. +- `CondNode`: A yellow hexagon with the task id, the text would be the `TaskWork` with that `workIdInJob`. +- `ParBegin`, `ParEnd`: A small black rectangle bar. +- `AltEnd`: A small pink rectangle bar. +- `CondBegin`: A small yellow rectangle bar. +- `CondEnd`: A small yellow diamond. +- `LoopEnd`: A small gray circle. + +For `TaskNode` and `CondNode`: + - The text would be the `TaskWork` with that `workIdInJob`. + - If `modelOverride` is specified, render it like `task (model)`. + +Each graph must have a border, and the background color would be lighter, except the black rectangle bar which has the same border and background color. + +There will be multiple arrows connecting between nodes: +- `ChartArrow.to` is the target `ChartNode` with that `id`. +- When `ChartArrow.loopBack` is true, it hints an arrow pointing upwards. All others should be downwards. +- `ChartArrow.label` is the label attached to the arrow. + +Arrows would be gray. + +#### Rendering with Mermaid + +Implementation stores in: +- `flowChartMermaid.js` + +No separate CSS file is needed; node styles are embedded as inline Mermaid `style` directives in the generated definition. + +Use [Mermaid.js](https://mermaid.js.org/) (loaded from CDN) for declarative flowchart rendering: +- Initialize Mermaid with `startOnLoad: false` so rendering is controlled programmatically. +- Build a Mermaid `graph TD` definition string from the `ChartGraph`. +- Each `ChartNode` becomes a Mermaid node with shape syntax matching its hint (rectangles, hexagons `{{}}`, circles `(())`). +- Each `ChartArrow` becomes a Mermaid edge with optional label. +- Per-node inline `style` directives set fill, stroke, and text color. +- Call `mermaid.render("mermaid-chart", definition)` to produce an SVG, then insert it into the container. +- The SVG viewBox must have enough padding (at least 24px) so that status indicator emojis on leftmost nodes are not clipped. + +#### Ctrl+Scroll Zoom + +When the user holds **Ctrl** and scrolls the mouse wheel over the `#chart-container`: +- The flow chart SVG scales up (scroll up) or down (scroll down) in discrete steps of 0.1. +- Zoom range is clamped between 0.2x and 3x, defaulting to 1x. +- CSS `transform: scale(...)` with `transform-origin: top left` is applied to the SVG element. +- The default browser scroll/zoom behavior is suppressed (`preventDefault`). + +#### Interaction with `ChartNode` which has a `TaskNode` or `CondNode` hint + +Clicking it select (exclusive) or unselect the text: +- When it becomes selected, the task is being inspected, `session response part` should display this task. +- When it becomes unselected, the task is not being inspected. `session response part` should restore. +- The border of the node becomes obviously thicker when selected. + +### Session Response Part + +When no task is being inspected, print `JSON.stringify(jobToRender and chartToRender, undefined, 4)` inside a `
` element.
+
+When a task is being inspected:
+- It becomes a tab control.
+- Each tab is a session, tab headers are names of sessions.
+  - The first tab will always be `Driving` and all driving sessions come to here.
+  - Each task session has its own tab.
+- Clicking a tab shows responses from a session using `Session Response Rendering` from `Shared.md`.
+- When the selected `ChartNode` is restarted, tabs should be cleared before adding new sessions.
diff --git a/.github/Agent/prompts/snapshot/CopilotPortal/JobsData.md b/.github/Agent/prompts/snapshot/CopilotPortal/JobsData.md
new file mode 100644
index 00000000..91b97fc1
--- /dev/null
+++ b/.github/Agent/prompts/snapshot/CopilotPortal/JobsData.md
@@ -0,0 +1,330 @@
+# Specification
+
+Root folder of the project is `REPO-ROOT/.github/Agent`.
+Read `README.md` to understand the whole picture of the project as well as specification organizations.
+
+## Related Files
+
+- `src/jobsDef.ts`
+- `src/jobsChart.ts`
+- `src/jobsData.ts`
+- `src/jobsApi.ts`
+
+## Functions
+
+### expandPromptStatic
+
+**Referenced by**:
+- JobsData.md: `### expandPromptDynamic`, `### validateEntry`
+
+A function convert from `Prompt` to `Prompt` with only one string.
+`Prompt` is a string array, the function should join them with LF character.
+In each string there might be variables.
+A variable is $ followed by one or multiple words connected with hyphens.
+When a variable is in `entry.promptVariables`, replace the variable with its values.
+When a variable is in `runtimeVariables`, keep them.
+When it fails to look up the value, report an error `${codePath}: Cannot find prompt variable: ${variableName}.`.
+Be aware of that, the value is still a `Prompt`, so it is recursive.
+
+When calls `expandPromptStatic` recursively for a resolved prompt variable,
+the `codePath` becomes `${codePath}/$${variableName}`.
+
+Report an error if `prompt` is empty.
+
+### expandPromptDynamic
+
+**Referenced by**:
+- JobsData.md: `## Running Tasks`
+
+It works like `expandPromptStatic`, but assert `prompt` has exactly one item.
+Look up all `runtimeVariables` in `values` argument.
+Be aware of that, not all variable has an value assigned.
+When it fails to look up the value, use `` as its value.
+
+### validateEntry
+
+**Referenced by**:
+- API_Task.md: `### copilot/test/installJobsEntry`
+
+Perform all verifications, verify and update all prompts with `expandPromptStatic`:
+- entry.tasks[name].prompt
+- entry.tasks[name].availability.condition (run extra verification)
+- entry.tasks[name].criteria.condition (run extra verification)
+- entry.tasks[name].criteria.failureAction.additionalPrompt
+
+When extra verification is needed,
+`expandPromptStatic`'s `requiresBooleanTool` will be set to true,
+it verifies that `job_boolean_true` or `job_boolean_false` must be mentioned in the expanded prompt.
+
+Here are all checks that `validateEntry` needs to do:
+- `entry.models.driving`:
+  - Should exist.
+- `entry.drivingSessionRetries`:
+  - Contains at least one item and the first modelId should equal to `entry.models.driving`.
+- `entry.grid[rowIndex].jobs[columnIndex].jobName`:
+  - Must be in keys of `entry.jobs`.
+- `entry.tasks[name].model.category`;
+  - Must be in fields of `entry.models`.
+- `entry.tasks[name].requireUserInput`:
+  - If it is true, its evaluated `prompt` should use `$user-input`, otherwise should not use.
+- `entry.tasks[name].criteria.toolExecuted[index]`:
+  - Must be in `availableTools`.
+- Any `TaskWork` in `entry.jobs[name]`, needs to inspect recursively:
+  - `TaskWork.taskId` must be in `entry.tasks`.
+  - `TaskWork.modelOverride.category` must be in fields of `entry.models`.
+  - `TaskWork.modelOverride` must be defined if that task has no specified model.
+- Any `SequentialWork` and `ParallelWork`:
+  - `works` should have at least one element.
+- `entry.jobs[name].requireUserInput`.
+  - Find out if a job requires user input by inspecting all `Task` record referenced by any `TaskWork` in this job.
+  - If any task `requireUserInput`, then the job `requireUserInput`, otherwise the job does not `requireUserInput`.
+  - If `Job.requireUserInput` is defined, it should reflect the correct value.
+  - If `requireUserInput` is undefined, fill it.
+  - After `validateEntry` finishes, all `Job.requireUserInput` should be filled.
+- `entry.jobs[name].work`:
+  - Simplifies the `work` tree:
+    - Expand nested `SequentialWork`, that a `SequentialWork` directly in another `SequentialWork` should be flattened. They could be multiple level.
+    - Expand nested `ParallelWork`, that a `ParallelWork` directly in another `ParallelWork` should be flattened. They could be multiple level.
+
+If any validation runs directly in this function fails:
+- The error code path will be `codePath` appended by the javascript expression without any variable.
+  - For example, if `entry.tasks[name].model` fails with task `scrum-problem-task`, the error code path becomes `${codePath}entry.tasks["scrum-problem-task"].model`.
+- It must throws an error like "${errorCodePath}: REASON".
+- The functions must also use the error code path for any `Prompt` expression to call `expandPromptStatic`.
+
+## Running Tasks
+
+**Referenced by**:
+- API_Task.md: `### sendMonitoredPrompt (crash retry)`
+- JobsData.md: `### TaskWork`
+
+A task is represented by type `Task`.
+
+A task can be running with `borrowing session mode` or `managed session mode`.
+
+### Borrowing Session Mode
+
+Every prompt will be running in the given session.
+If the session crashes, fail the task immediately.
+
+### Managed Session Mode
+
+Before starting a task, it needs to decide if the task is running in single model or multiple models.
+Single model option will be enabled when one of the following conditions satisfies:
+- `Task.criteria` is undefined.
+- `Task.criteria.runConditionInSameSession` is undefined or it is true.
+
+Every session is created and managed by the task.
+No matter single model or multiple models is selected:
+- If a session crashes, new session must be created to replace it.
+- If executing the same prompt results in consecutive crashes and eventually draining all retry budget, fail the task immediately.
+- Add `SESSION_CRASH_PREFIX` (const from `taskApi.ts`: `"The session crashed, please redo and here is the last request:\n"`) before the prompt when resend.
+- The crash retry logic is implemented in the private `sendMonitoredPrompt` method on `CopilotTaskImpl` in `taskApi.ts`.
+- The exception cannot be consumed silently, and every exception should be reported by `ICopilotTaskCallback.taskDecision`.
+
+#### Retry Budget
+
+Task session retry budget is defined in `Task.criteria.failureAction`.
+Driving session retry budget is defined in `entry.drivingSessionRetries`.
+When en single mode, retry budgets are still separately applying, depending on if the session is running driving session work (availability/criteria check) or task session work.
+
+Driving session retries in this way:
+- Ensure `entry.model.driving === entry.drivingSessionRetries[0].modelId`. This should have been ensured by `validateEntry` but assert in here again.
+  - This should be done in `startTask` and therefore assume it always satisfies.
+- Open a `number[]` array copying all `entry.drivingSessionRetries[index].retries`.
+- For each `entry.drivingSessionRetries[index]`, if `retryBudget[index]` > 0, decreases it and perform the retry.
+  - Retry with the appriopriate prompt for each using `modelId`.
+  - This mean try all candidate models in each round, but pay attention to the retry budget as they are different per model.
+- Keep doing until `retryBudget` contains only zeros, which means all retyr budget trained.
+- Budget refreshes for every driving mission (which the current implementation is already doing but it follows the old retry budget design).
+
+#### Managed Session Mode (single model)
+
+A session will be created, and it serves both a driving session and a task session.
+`Entry.models[Task.model]` will be used. When `Task.model` is undefined, the model id should be assigned from `startTask`.
+
+#### Managed Session Mode (multiple models)
+
+Session are created only when needed, and it is closed when its mission finishes.
+
+The mission of a driving session is:
+- Perform one `Task.availability.condition` check.
+- Perform one `Task.criteria.condition` check.
+- Finishing the check closes the driving session
+
+The mission of a task session is:
+- Perform one `Task.prompt`.
+- Finishing one `Task.prompt` closes the task session
+
+The driving session uses `Entry.models.driving`.
+The task session uses `Entry.models[Task.model]`. When `Task.model` is undefined, the model id should be assigned.
+
+#### Preprocess Prompt
+
+Before sending any prompt to the driving or task session,
+`expandPromptDynamic` will be called to apply runtime variables.
+This function must be called every time a prompt is sent to the session,
+because runtime variables could change.
+
+### Tools and Runtime Variables
+
+**Referenced by**:
+- JobsData.md: `### expandPromptDynamic`, `### Task.availability`, `### Task.criteria`, `### validateEntry`
+
+`$user-input` will be passed from the user directly.
+`$task-model` will be the model name selected to run the task session. It is not the category, it is the actual model name.
+
+The following tools could be called in the driving or task session.
+- When `job_prepare_document` is called, its argument becomes `$reported-document`. If there is multiple line, only keep the first line and trim and space characters before and after it.
+- When the `job_boolean_true` tool is called, the condition satisfies.
+  - The argument will be assigned to the `$reported-true-reason` runtime variable.
+  - `$reported-false-reason` will be deleted.
+- When the `job_boolean_false` tool is called, the condition fails.
+  - The argument will be assigned to the `$reported-false-reason` runtime variable.
+  - `$reported-true-reason` will be deleted.
+
+### Task.availability
+
+**Referenced by**:
+- JobsData.md: `### TaskWork`, `### validateEntry`
+- API_Task.md: `### copilot/task/start/{task-name}/session/{session-id}`
+
+If `Task.availability` is not defined,
+there will be no prerequisite checking,
+the task just run.
+
+All conditions must satisfy at the same time to run the task:
+- When `Task.availability.condition` is defined, the condition must satisfy.
+  - The driving session will run the prompt.
+  - The condition satisfies when the `job_boolean_true` is called in this round of driving session response.
+
+otherwise the `job_prerequisite_failed` tool will be called in the driving session,
+indicating the task fails.
+
+### Task.criteria
+
+**Referenced by**:
+- JobsData.md: `### TaskWork`, `### validateEntry`
+- API_Task.md: `### copilot/task/start/{task-name}/session/{session-id}`
+
+If `Task.criteria` is not defined,
+there will be no criteria checking,
+the task is treat as succeeded.
+
+All conditions must satisfy to indicate that the task succeeded:
+- When `Task.criteria.toolExecuted` is defined, all tools in the list should have been executed in the last round of task session response.
+  - When retrying the task due to `toolExecuted`, append `## Required Tool Not Called: {tool names ...}`.
+- When `Task.criteria.condition` is defined:
+  - The driving session will run the prompt.
+  - The condition satisfies when the `job_boolean_true` is called in this round of driving session response.
+
+The task should react to `Task.criteria.failureAction` when task execution does not satisfy the condition:
+- Retry at most `retryTimes` times.
+- Send the original prompt, extra prompt is appended if:
+  - The criteria test fails due to `toolExecuted`: append `## Required Tool Not Called: {tool names ...}`.
+  - `additionalPrompt` defines: append `"## You accidentally Stopped"` followed by the `additionalPrompt`.
+
+### Calling ICopilotTaskCallback.taskDecision
+
+In above sessions there are a lot of thing happenes in the driving session. A reason should be provided to `taskDecision`, including but not limited to:
+- The availability test passed.
+- The availability test failed with details.
+- The criteria condition test passed.
+- The criteria condition test failed with details.
+- Starting a retry with retry number.
+- Retry budget drained because of availability or criteria failure.
+- Retry budget drained because of crashing.
+  - These two budgets are separated: crash retries are per-call (5 max in `sendMonitoredPrompt`), criteria retries are per failure action loop. A crash exhausting its per-call budget during a criteria retry loop is treated as a failed iteration rather than killing the task.
+- Any error generated in the copilot session.
+- A final decision about the task succeeded or failed.
+
+Information passing to `taskDecision` should include the following prefix in order to better categories:
+- `[SESSION STARTED] (driving|task) session started with model {modelId}`, always say `task session` for single managed session mode.
+- `[SESSION CRASHED]` with detailed information from the exception.
+- `[TASK SUCCEEDED]`
+- `[TASK FAILED]`
+- `[AVAILABILITY]`
+- `[CRITERIA]`
+- `[OPERATION]`
+- `[DECISION]`
+- `[MISC]`
+
+For `availability` and `criteria`, the information should at least contain:
+- The task does not match which field and what is the content of the field (field in `availability` and `criteria` properties)
+- Why it does not match
+
+## Running Jobs
+
+**Referenced by**:
+- API_Task.md: `### sendMonitoredPrompt (crash retry)`
+
+A `Job` is workflow of multiple `Task`. If its work fails, the job fails.
+
+### Work
+
+**Referenced by**:
+- JobsData.md: `### TaskWork`, `### Determine TaskWork.workId`
+- API_Job.md: `### copilot/job/start/{job-name}`
+
+- `TaskWork`: run the task, if `modelOverride` is defined that model is used.
+  - If `category` is defined, the model id is `entry.models[category]`.
+  - Otherwise `id` is the model id.
+- `SequentialWork`, run each work sequentially, any failing work immediately fails the `SequentialWork` without continuation.
+  - Empty `works` makes `SequentialWork` succeeds.
+- `ParallelWork`, run all works parallelly, any failing work fails the `ParallelWork`, but it needs to wait until all works finishes.
+  - Empty `works` makes `ParallelWork` succeeds.
+- `LoopWork`:
+  - Before running `body`, if `preCondition` succeeds (first element is true) or fails (first element is false), run `body`, otherwise `LoopWork` finishes as succeeded.
+  - After running `body`, if `postCondition` succeeds (first element is true) or fails (first element is false), redo `LoopWork`, otherwise `LoopWork` finishes as succeeded.
+  - If `body` fails, `LoopWork` finishes and fail.
+- `AltWork`:
+  - If `condition` succeeds, choose `trueWork`, otherwise choose `falseWork`.
+  - If the chosen work is undefined, `AltWork` finishes as succeeded.
+  - If the chosen work succeeds, `AltWork` finishes as succeeded.
+
+**TEST-NOTE-BEGIN**
+Need individual test cases for each type of `Work` in `work.test.mjs`, verifying details of each statement in the above bullet-point.
+Such test case could be implemented by making up a job and calls `api/copilot/job/start/{job-name}` to start a work.
+You can firstly obtain the updated work by calling `api/copilot/job`, find your target job, `workIdForJob` should have been attached to each `TaskWork`.
+By calling the `api/copilot/job/{job-id}/live/{token}` api, you are able to see the starting and ending order of each `TaskWork`, by their own `workIdForJob`.
+
+With such information, you can verify:
+- If the job succeeded or failed as expected.
+- If each `TaskWork` actually triggered in the expected order or logic.
+  - For `AltWork`, only one of `trueWork` or `falseWork` triggers.
+  - For `ParallelWork`, all `works` should trigger but the order may vary.
+- Any task could fail, assert its side effect on the control flow.
+  - For example, if `AltWork.condition` succeeds but a defined `trueWork` does not happen, there should be problems.
+  - You need to check all possible equivalence classes of execution paths according to the control flow.
+
+More details for api and additional test notes could be found in `API.md`.
+**TEST-NOTE-END**
+
+### TaskWork
+
+**Referenced by**:
+- JobsData.md: `### Determine TaskWork.workId`
+
+When a task is executed by a `TaskWork`, it is in `managed session mode`.
+The job has to start all sessions.
+`TaskWork` fails if the last retry:
+- Does not pass `Task.availability` checking. Undefined means the check always succeeds.
+- Does not pass `Task.criteria` checking. Undefined means the check always succeeds.
+
+### Determine TaskWork.workId
+
+**Referenced by**:
+- API_Job.md: `### copilot/job`
+
+Any `TaskWork` must have an unique `workIdInJob` in a Job.
+The `assignWorkId` function converts a `Work` to `Work` with property `workIdInJob` assigned.
+When creating a `Work` AST, you can create one in `Work` without worrying about `workIdInJob`, and call `assignWorkId` to fix that for you.
+
+### Exception Handling
+
+**Referenced by**:
+- API_Task.md: `### sendMonitoredPrompt (crash retry)`
+
+If any task crashes:
+- The job stops immediately and marked failed.
+- The exception cannot be consumed silently.
diff --git a/.github/Agent/prompts/snapshot/CopilotPortal/Shared.md b/.github/Agent/prompts/snapshot/CopilotPortal/Shared.md
new file mode 100644
index 00000000..03914f8a
--- /dev/null
+++ b/.github/Agent/prompts/snapshot/CopilotPortal/Shared.md
@@ -0,0 +1,122 @@
+# Specification
+
+Root folder of the project is `REPO-ROOT/.github/Agent`.
+Read `README.md` to understand the whole picture of the project as well as specification organizations.
+
+## Related Files
+
+- `assets`
+  - `messageBlock.css`
+  - `messageBlock.js`
+  - `sessionResponse.css`
+  - `sessionResponse.js`
+
+### messageBlock.css
+
+Put messageBlock.js specific css file in messageBlock.css.
+
+### messageBlock.js
+
+**Referenced by**:
+- Shared.md: `### sessionResponse.js`
+
+It exposes some APIs in this schema
+
+```typescript
+export class MessageBlock {
+  constructor(blockType: "User" | "Reasoning" | "Tool" | "Message");
+  appendData(data: string): void;
+  replaceData(data: string): void;
+  complete(): void;
+  get isCompleted(): boolean;
+  get divElement(): HTMLDivElement;
+  get title(): string;
+  set title(value: string);
+}
+
+export function getMessageBlock(div: HTMLDivElement): MessageBlock | undefined;
+```
+
+Each `MessageBlock` has a title, displaying: "blockType (title) [receiving...]" or "blockType (title)".
+When `title` is empty, "()" and the space before it must be omitted.
+Receiving appears when it is not completed yet.
+
+When a `MessageBlock` is created and receiving data, the height is limited to 150px, clicking the header does nothing
+
+When a `MessageBlock` is completed:
+- If this `MessageBlock` is "User" and "Message", it will expand, otherwise collapse.
+- Completing a `MessageBlock` should not automatically expand or collapse other blocks.
+- Clicking the header of a completed `MessageBlock` switch between expanding or collapsing.
+- There is no more height limit, it should expands to render all data.
+- A button appears at the very right of the header, it should fills full height.
+  - When the content is rendering as markdown, it shows "View Raw Data", clicking it shows the raw data.
+  - When the content is raw data, it shows "View Markdown", clicking it shows the markdown rendering of the raw data.
+
+Before a `MessageBlock` is completed, raw data should render.
+After it is completed, assuming the data is markdown document and render it properly:
+- Except for "Tool" block, and "Tool" block should not render the button switching between raw data and markdown.
+- Try to tell if the raw content is markdown or just ordinary text, if it doesn't look like a markdown, do not do the markdown rendering automatically.
+
+Inside the `MessageBlock`, it holds a `
` to change the rendering. +And it should also put itself in the element (e.g. in a field with a unique name) so that the object will not be garbage-collected. + +### sessionResponse.css + +Put sessionResponse.js specific css file in sessionResponse.css. + +### sessionResponse.js + +**Referenced by**: +- Index.md: `#### Session Part`, `#### Session Interaction`, `#### Request Part` + +It exposes a `SessionResponseRenderer` class in this schema: + +```typescript +export class SessionResponseRenderer { + constructor(div: HTMLDivElement); + processCallback(data: object): string; + addUserMessage(text: string, title?: string): void; + setAwaiting(awaiting: boolean): void; + scrollToBottom(): void; +} +``` + +`SessionResponseRenderer` is a pure rendering component that does not touch any Copilot related API. +An empty div element is passed to the constructor; all child elements are dynamically created inside it. +The container div gets a CSS class `session-response-container` for styling (scrollable, padded). + +It manages an internal map of `MessageBlock` instances keyed by `"blockType-blockId"`. +An "Awaits responses ..." status element is created and appended to the container. + +- `processCallback(data)`: Processes a callback object (from the live polling API) and handles creating/updating/completing message blocks for Reasoning, Message, Tool, and onGeneratedUserPrompt callbacks. For `onGeneratedUserPrompt`, it calls `addUserMessage(data.prompt, "Task")`. Returns the callback name string so the caller can react to lifecycle events (e.g. `"onAgentEnd"`). +- `addUserMessage(text)`: Creates a "User" `MessageBlock`, appends the text, completes it, and scrolls to bottom. +- `setAwaiting(awaiting)`: Shows or hides the "Awaits responses ..." status text. +- `scrollToBottom()`: Scrolls the container to the bottom. + +#### Session Response Rendering + +Session responses generates 3 types of message block: +- Reasoning +- Tool +- Message +Multiple of them could happen parallelly. + +When `ICopilotSessionCallbacks::onStartXXX` happens, a new message block should be created. +When `ICopilotSessionCallbacks::onXXX` happens, the data should be appended (`appendData`) to the message block. +When `ICopilotSessionCallbacks::onEndReasoning/onEndMessage` happens, replace (`replaceData`) the message block with the complete contet. + +The content of a "Tool" `MessageBlock` needs to be taken care of specially: +- The first line should be in its title. It is easy to tell when the `title` property is empty. +- `ICopilotSessionCallbacks::onEndToolExecution` will give you optional extra information. +Responses for different message blocks are identified by its id. + +Message blocks stack vertically from top to bottom. +`MessageBlock` in messageBlock.js should be used to control any message block. + +You are recommended to maintain a list of message blocks in a map with key "blockType-blockId" in its rendering order. + +When `setAwaiting(true)` is called, +there must be a text at the left bottom corner of the session part saying "Awaits responses ...". +When `setAwaiting(false)` is called, this text disappears. + +The session response container is scrollable. diff --git a/.github/Agent/prompts/snapshot/CopilotPortal/Test.md b/.github/Agent/prompts/snapshot/CopilotPortal/Test.md new file mode 100644 index 00000000..5fda978d --- /dev/null +++ b/.github/Agent/prompts/snapshot/CopilotPortal/Test.md @@ -0,0 +1,13 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `assets` + - `test.html` + +### test.html + +This is a test page, when it is loaded, it queries `api/test` and print the message field to the body. diff --git a/.github/Agent/prompts/spec.prompt.md b/.github/Agent/prompts/spec.prompt.md new file mode 100644 index 00000000..6abe84b7 --- /dev/null +++ b/.github/Agent/prompts/spec.prompt.md @@ -0,0 +1,116 @@ +# Spec Driven Development + +The root of this project `REPO-ROOT/.github/Agent` is not the workspace root, +you need to cd/pushd to this folder before calling yarn and nodejs. +The CopilotPortal package serves a website and a RESTful API that can be tested with playwright chromium. +All folders and files mentioned in the instructions are in the `REPO-ROOT/.github/Agent` folder. + +When this file is tagged, find out new changes in the spec: +- Delete all files in `prompts/snapshot`. +- Copy all files in `prompts/spec` to `prompts/snapshot`. +- You can see what has been changed in the spec by git diff. + - Ignore CRLF and LF differences if there is any. +- Changes includes not only spec files changing but also adding or removing. +- You are recommended to read `README.md` to understand the whole picture of the project as well as specification organizations. +- You are recommended to read all spec files to understand dependencies and relationships between each component. +- Implement all changes. + +**IMPORTANT**: If you find any mistake in the spec causing you not being able to strictly follow, fix the spec. +**IMPORTANT**: Always use `api/stop` to stop `yarn portal`, trying to ENTER will hang the terminal forever and you can't proceed. +**IMPORTANT**: Avoid global variables at all cost, until you find no other way. +**IMPORTANT**: This project is full of parallel tasks, try to keep all functions re-entrable. +**IMPORTANT**: DO NOT add dynamic properties to any object. If the interface need to change, change the interface and the spec together. + +## Changing the Spec + +You are allowed to change the spec. And you must change the spec when you decide to: +- Add a new feature but the spec doesn't describe it. +- Change the implementation when the spec describes the old one. +- Delete a feature but the spec describes it. + +When you think the spec needs to update, +You will have to change files in both `prompts/snapshot` and `prompts/spec`, keep them sync. + +### Fixing Bugs + +You might find some lines begins with `**BUG**:` in the spec. +It means you need to fix the bug about the surrounding context. +Delete the bug from the spec after you fix it, keep `prompts/snapshot` and `prompts/spec` sync. + +### Performing Tasks + +You might find some lines begins with `**TASK**:` in the spec, or `**TASK-BEGIN**` to its nearest `**TASK-END**` blocks. +It means you need to complete the task described in the surrounding context. +Delete the task from the spec after you complete it, keep `prompts/snapshot` and `prompts/spec` sync. + +### Maintaining "Referenced By" + +There are bullet lists under `**Referenced by**:` for most of the sessions, their format is: +``` +- SpecFile.md: `# This Section`, `### That Section`... +``` +Each file occupys one line. +It means the behavior of those sections depend on the current section, when the current section is changed, those sections probably need to check. +Maintain the list when you find new or outdated dependencies. + +### Interface Changes + +It is very often that you need to change the interface because of the spec changes, but they are not mentioned. +You need to update the spec with your interface changes too. + +**IMPORTANT**: +- The list coule be incomplete, it is just a hint. +- You should maintain this list automatically. + +## Testing with a Free Model + +There are some models whose multiplier is 0. +Try your best to pick them, therefore it doesn't cost for performing any testing. +List all models by `api/copilot/models`. + +## RESTful API Testing + +When RESTful API implementation is changed, +you are recommended to start the server and test the API directly first. +Walk through all APIs. +Pay attention to the working directory argument in the API, you can use the `REPO-ROOT` of this project. +Make some random conversation without making any side effect to the project (specified by working directory argument). + +## Playwright Testing + +When the website is changed, you must always try all webpage features described in the spec. + +## Maintaining Unit Test + +It starts a copilot session so it could be hard to predict what the response will be. +But try your best to design and maintain a set of unit test files in the `test` folder of any working package. +Cover as much as you can against RESTful API and website features. + +There will be **TEST-NOTE** or **TEST-NOTE-BEGIN** to **TEST-NOTE-END** blocks in the spec. +They applies to the limited context surrounding them. +They offer ideas of how to perform testing for a certain feature that is complex and not easy to do, or to skip something that is really not able to be tested. +Having a test note doesn't mean it is enough to only cover what a test note says. You must always do complete testing. When testing against a feature with a test note, follow the note to organize your test cases. + +When any feature is changed, update the unit test as well. + +**IMPORTANT**: All test cases should pass, no matter if they are related to the current change or not. + +## Post Implementation + +Remember to update `README.md` to describe: +- What this project is. +- What does it use. +- How to maintain and run the project. +- Brief description of the project structure. + +Always send the `api/stop` to kill the server no matter it is running or not. + +### Git Push + +This section only applies when you are running locally (aka not running in github.com maintaining a pull request). + +When you think you have implemented all changes and all tests including playwright and unit test pass, +git commit the change with title "Updated .github/Agent" and git push. +Git push may fail when the remote branch has new commits: +- Do the merge when it could be automatically done. And then git push again. +- If there is any merge conflict, tell me and stop. diff --git a/.github/Agent/prompts/spec/CopilotPortal/API.md b/.github/Agent/prompts/spec/CopilotPortal/API.md new file mode 100644 index 00000000..022b7cb3 --- /dev/null +++ b/.github/Agent/prompts/spec/CopilotPortal/API.md @@ -0,0 +1,152 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `src/copilotSession.ts` +- `src/sharedApi.ts` +- `src/copilotApi.ts` +- `src/taskApi.ts` +- `src/jobsApi.ts` +- `src/index.ts` + +Data structures about jobs and tasks are in `src/jobsDef.ts`. +Its spec is in `JobsData.md`. + +## Starting the HTTP Server + +**Referenced by**: +- API_Task.md: `### copilot/test/installJobsEntry` + +- This package starts an http server, serving a website as well as a set of RESTful API. +- In src/index.ts it accepts command line options like this: + - `--port 8888`: Specify the http server port. `8888` by default. + - `--test`: `installJobsEntry` is not called, an extra API `copilot/test/installJobsEntry` is available only in this mode. +- Website entry is http://*:port +- API entry is http://*:port/api/... +- "yarn portal" to run src/index.ts. +- "yarn portal-for-test" to run src/index.ts in test mode + +It starts both Website and RESTful API. Awaits for api/stop to stop. +Prints the following URL for shortcut: +- http://localhost:port +- http://localhost:port/api/stop + +## Running the Website + +- http://*:port is equivalent to http://*:port/index.html. +- In the assets folder there stores all files for the website. +- Requesting for http://*:port/index.html returns assets/index.html. + +## Helpers (index.ts) -------------------------------------------------------------------------------------------------------------------------- + +### installJobsEntry + +`async installJobsEntry(entry: Entry): Promise;` +- Use the entry. It could be `entry` from `jobsData.ts` or whatever. +- This function can only be called when no session is running, otherwise throws. + +### ensureInstalledEntry + +**Referenced by**: +- API_Task.md: `### copilot/task/start/{task-name}/session/{session-id}` +- API_Job.md: `copilot/job/start/{job-name}` + +`ensureInstalledEntry(): Entry` +- Throw an error if `installJobsEntry` has not been called. +- Return the installed entry. + +## Helpers (copilotApi.ts) -------------------------------------------------------------------------------------------------------------------------- + +All helper functions and types are exported and API implementations should use them. + +### helperGetModels + +**Referenced by**: +- API.md: `### copilot/models` + +`async helperGetModels(): Promise` +- List all models. + +## API (copilotApi.ts) ------------------------------------------------------------------------------------------------------------------------------ + +All restful read arguments from the path and returns a JSON document. + +All title names below represents http:/*:port/api/TITLE + +Copilot hosting is implemented by `@github/copilot-sdk` and `src/copilotSession.ts`. + +### config + +**Referenced by**: +- Index.md: `#### Starting a Copilot Session` + +Returns the repo root path (detected by walking up from the server's directory until a `.git` folder is found). + +```typescript +{ + repoRoot: string; +} +``` + +### token + +Returns a random generated GUID everytime, no special meaning. +No need to store generated tokens. + +### stop + +**Referenced by**: +- Index.md: `#### Request Part` +- Jobs.md: `### Matrix Part` + +Stop any running sessions. +Returns `{}` and stops. + +### copilot/models + +**Referenced by**: +- Index.md: `#### Starting a Copilot Session` + +Returns all copilot sdk supported models in this schema + +```typescript +{ + models: { + name: string; + id: string; + multiplier: number; + }[] +} +``` + +## API (index.ts) ------------------------------------------------------------------------------------------------------------------------------ + +### test + +**Referenced by**: +- Test.md: `### test.html` + +Returns `{"message":"Hello, world!"}` + +### copilot/test/installJobsEntry + +Only available when `src/index.ts` is launched with `--test`. +The body will be an absolute path of a custom JSON file for entry. +The API will first check if the JSON file is in the `test` folder. +It reads the JSON file, called `validateEntry` followed by `installJobsEntry`. + +Return in this schema: +```typescript +{ + result: "OK" | "InvalidatePath" | "InvalidateEntry" | "Rejected", + error?: string +} +``` + +Return "InvalidatePath" when the file is not in the `test` folder. +Return "InvalidateEntry" when `validateEntry` throws. +Return "Rejected" when `installJobsEntry` throws. +"error" stores the exception message. diff --git a/.github/Agent/prompts/spec/CopilotPortal/API_Job.md b/.github/Agent/prompts/spec/CopilotPortal/API_Job.md new file mode 100644 index 00000000..5a10e241 --- /dev/null +++ b/.github/Agent/prompts/spec/CopilotPortal/API_Job.md @@ -0,0 +1,234 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `src/sharedApi.ts` +- `src/jobsApi.ts` +- `src/index.ts` + +Data structures about jobs and tasks are in `src/jobsDef.ts`. +Its spec is in `JobsData.md`. + +If an api requires `Entry`: +- The first argument of the api helper function must be `entry: Entry`. +- `index.ts` will call `ensureInstalledEntry` for this argument. + +**TEST-NOTE-BEGIN** +`yarn launch-for-test` will be used for unit testing therefore you have a chance to specify your own entry file. +DO NOT use the default entry for testing. + +The free model "gpt-5-mini" must be used in every `Entry.models`. +If the model is no longer available, choose a new one and update both spec and custom entry JSON files. +**TEST-NOTE-END** + +## Helpers (jobsApi.ts) ----------------------------------------------------------------------------------------------------------------------------- + +```typescript +interface ICopilotJob { + get runningWorkIds(): number[]; + get status(): "Executing" | "Succeeded" | "Failed"; + // stop all running tasks, no further callback issues. + stop(): void; +} + +interface ICopilotJobCallback { + // Called when this job succeeded + jobSucceeded(): void; + // Called when this job failed + jobFailed(): void; + // Called when this job failed + jobCanceled(): void; + // Called when a TaskWork started, taskId is the registered task for live polling + workStarted(workId: number, taskId: string): void; + // Called when a TaskWork stopped + workStopped(workId: number, succeeded: boolean): void; +} + +async function startJob( + entry: Entry, + jobName: string, + userInput: string, + workingDirectory: string, + callback: ICopilotJobCallback +): Promise +``` + +## API (jobsApi.ts) --------------------------------------------------------------------------------------------------------------------------------- + +All restful read arguments from the path and returns a JSON document. + +All title names below represents http:/*:port/api/TITLE + +Job hosting is implemented by and `src/jobsApi.ts`. + +### copilot/job + +**Referenced by**: +- Jobs.md: `### jobs.html`, `### Matrix Part` +- Jobs.md: `### jobTracking.html` + +List all jobs passed to `installJobsEntry` in this schema: +```typescript +{ + grid: GridRow[]; + jobs: { [key in string]: Job }; + chart: { [key in string]: ChartGraph }; +} +``` + +Basically means it only keeps `grid` and `jobs` and drops all other fields, and calculate `chart` with `generateChartNodes`. + +### copilot/job/running + +List all jobs that: +- Running +- Finished less than an hour + +in this schema: + +```typescript +{ + jobs: { + jobId: string; + jobName: string; + startTime: Date; + status: "Running" | "Succeeded" | "Failed" | "Canceled"; + }[]; +} +``` + +### copilot/job/{job-id}/status + +If a job is running or finished less than an hour, return in this schema: + +```typescript +{ + jobId: string; + jobName: string; + startTime: Date; + status: "Running" | "Succeeded" | "Failed" | "Canceled"; + tasks: { + workIdInJob: number; + taskId?: string; // available only when running + status: "Running" | "Succeeded" | "Failed"; + }[]; +} +``` + +otherwise: + +```typescript +{ + error: "JobNotFound" +} +``` + +### copilot/job/start/{job-name} + +**Referenced by**: +- Jobs.md: `#### Clicking Start Job Button` + +**TEST-NOTE-BEGIN** +Besides of testing API failures, it is important to make sure job running works. + +Create test cases for running a job, focused on different types of `Work`s. +Test every kinds of `Work` and ensure: +- It succeeds when all involved tasks succeed. Should test against every type of `Work`. +- If fails properly. Should test against every type of `Work`, in each possible failure position. +- Whenever the job succeeded or failed, the live api responses correctly. +- Execution of tasks in `Work` should be observable from the live api. + +Skip testing against task crashes scenarios because it is difficult to make it crash. +**TEST-NOTE-END** + +The first line will be an absolute path for working directory +The rest of the body will be user input. + +Start a new job and return in this schema. +The job remembers its `job-name` and start time. +It also remembers status of all tasks. When a task restarts, status overrides. + +```typescript +{ + jobId: string; +} +``` + +or when error happens: + +```typescript +{ + error: "JobNotFound" +} +``` + +### copilot/job/{job-id}/stop + +**Referenced by**: +- Jobs.md: `### Job Part` + +A job will automatically stops when finishes, +this api forced the job to stop. + +Stop the job and return in this schema. + +```typescript +{ + result: "Closed" +} +``` + +or when error happens: + +```typescript +{ + error: "JobNotFound" +} +``` + +### copilot/job/{job-id}/live/{token} + +**Referenced by**: +- Jobs.md: `### Job Part`, `### Session Response Part`, `### jobTracking.html` + +It works like `copilot/session/{session-id}/live/{token}` but it reacts to `ICopilotJobCallback`. +They should be implemented in the same way, but only respond in schemas mentioned below. +`token` can be obtained by `/token` but no checking needs to perform. + +Returns in this schema when responses are available (batched, same as session live API): + +```typescript +{ + responses: LiveResponse[] +} +``` + +Returns in this schema if any error happens + +```typescript +{ + error: "JobNotFound" | "JobsClosed" | "HttpRequestTimeout" | "ParallelCallNotSupported" +} +``` + +Special `JobNotFound` and `JobsClosed` handling for this API: +- Works in the same way as `SessionNotFound` and `SessionClosed` in `copilot/session/{session-id}/live/{token}`. + +**TEST-NOTE-BEGIN** +Can't trigger "HttpRequestTimeout" stably in unit test so it is not covered. +It requires the underlying copilot agent to not generate any response for 5 seconds, +which is almost impossible. +**TEST-NOTE-END** + +An element in the `responses` array with this schema represents an exception thrown from inside the session + +```typescript +{ + jobError: string +} +``` + +Each element in the `responses` array maps to a method in `ICopilotJobCallback` in `src/jobsApi.ts`. diff --git a/.github/Agent/prompts/spec/CopilotPortal/API_Session.md b/.github/Agent/prompts/spec/CopilotPortal/API_Session.md new file mode 100644 index 00000000..541c8899 --- /dev/null +++ b/.github/Agent/prompts/spec/CopilotPortal/API_Session.md @@ -0,0 +1,331 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `src/copilotSession.ts` +- `src/sharedApi.ts` +- `src/copilotApi.ts` +- `src/index.ts` + +Data structures about jobs and tasks are in `src/jobsDef.ts`. +Its spec is in `JobsData.md`. + +If an api requires `Entry`: +- The first argument of the api helper function must be `entry: Entry`. +- `index.ts` will call `ensureInstalledEntry` for this argument. + +**TEST-NOTE-BEGIN** +`yarn launch-for-test` will be used for unit testing therefore you have a chance to specify your own entry file. +DO NOT use the default entry for testing. + +The free model "gpt-5-mini" must be used in every `Entry.models`. +If the model is no longer available, choose a new one and update both spec and custom entry JSON files. +**TEST-NOTE-END** + +## Helpers (copilotApi.ts) -------------------------------------------------------------------------------------------------------------------------- + +All helper functions and types are exported and API implementations should use them. + +### helperSessionStart + +**Referenced by**: +- API_Session.md: `### copilot/session/start/{model-id}` + +`async helperSessionStart(modelId: string, workingDirectory?: string): Promise<[ICopilotSession, string]>;` +- Start a session, return the session object and its id. + +### helperSessionStop + +**Referenced by**: +- API_Session.md: `### copilot/session/{session-id}/stop` +- API.md: `### stop` + +`async helperSessionStop(session: ICopilotSession): Promise;` +- Stop a session. + +### helperGetSession + +**Referenced by**: +- API_Session.md: `### copilot/session/{session-id}/stop`, `### copilot/session/{session-id}/query`, `### copilot/session/{session-id}/live/{token}` + +`helperGetSession(sessionId: string): ICopilotSession | undefined;` +- Get a session by its id. + +### helperPushSessionResponse + +**Referenced by**: +- API_Session.md: `### copilot/session/{session-id}/live/{token}` + +`helperPushSessionResponse(session: ICopilotSession, response: LiveResponse): void;` +- Push a response to a session's response queue. + +### hasRunningSessions + +**Referenced by**: +- API.md: `### stop` + +`hasRunningSessions(): boolean;` +- Returns true if any sessions are currently running. + +## Helpers (copilotSession.ts) ---------------------------------------------------------------------------------------------------------------------- + +Wraps `@github/copilot-sdk` to provide a simplified session interface with event callbacks. + +### ICopilotSession + +**Referenced by**: +- API_Session.md: `### helperSessionStart`, `### helperSessionStop`, `### helperGetSession`, `### helperPushSessionResponse`, `### startSession` + +```typescript +interface ICopilotSession { + get rawSection(): CopilotSession; + sendRequest(message: string, timeout?: number): Promise; + stop(): Promise +} +``` +- Interface for interacting with a Copilot session. +- When a session is no longer using, `stop` must be called. `CopilotSession.destroy` should be called by `stop`. + +### ICopilotSessionCallbacks + +**Referenced by**: +- API_Session.md: `### copilot/session/{session-id}/live/{token}`, `### startSession` +- Index.md: `#### Session Interaction`, `#### Request Part` +- Shared.md: `#### Session Response Rendering` + +```typescript +interface ICopilotSessionCallbacks { + onStartReasoning(reasoningId: string): void; + onReasoning(reasoningId: string, delta: string): void; + onEndReasoning(reasoningId: string, completeContent: string): void; + onStartMessage(messageId: string): void; + onMessage(messageId: string, delta: string): void; + onEndMessage(messageId: string, completeContent: string): void; + onStartToolExecution(toolCallId: string, parentToolCallId: string | undefined, toolName: string, toolArguments: string): void; + onToolExecution(toolCallId: string, delta: string): void; + onEndToolExecution(toolCallId: string, result: { content: string; detailedContent?: string } | undefined, error: { message: string; code?: string } | undefined): void; + onAgentStart(turnId: string): void; + onAgentEnd(turnId: string): void; + onIdle(): void; +} +``` +- Callback interface for all session events. + +### startSession + +**Referenced by**: +- API_Session.md: `### copilot/session/start/{model-id}` + +`async startSession(client: CopilotClient, modelId: string, callback: ICopilotSessionCallbacks, workingDirectory?: string): Promise;` +- Create a session with the given model, register job tools, wire up all event handlers, and return an `ICopilotSession`. + +## API (copilotApi.ts) ------------------------------------------------------------------------------------------------------------------------------ + +All restful read arguments from the path and returns a JSON document. + +All title names below represents http://*:port/api/TITLE + +Copilot hosting is implemented by `@github/copilot-sdk` and `src/copilotSession.ts`. + +### copilot/session/start/{model-id} + +**Referenced by**: +- Index.md: `##### Start Button` + +The body will be an absolute path for working directory + +Start a new copilot session and return in this schema + +```typescript +{ + sessionId: string; +} +``` + +or when error happens: + +```typescript +{ + error: "ModelIdNotFound" | "WorkingDirectoryNotAbsolutePath" | "WorkingDirectoryNotExists" +} +``` + +Multiple sessions could be running parallelly, start a `CopilotClient` if it is not started yet, it shares between all sessions. +The `CopilotClient` will be closed when the server is shutting down. + +### copilot/session/{session-id}/stop + +**Referenced by**: +- Index.md: `#### Request Part` + +Stop the session and return in this schema + +```typescript +{result:"Closed"} | {error:"SessionNotFound"} +``` + +### copilot/session/{session-id}/query + +**Referenced by**: +- Index.md: `#### Request Part` + +The body will be the query prompt string. + +Send the query to the session, and the session begins to work. + +Returns in this schema + +```typescript +{ + error?:"SessionNotFound" +} +``` + +### copilot/session/{session-id}/live/{token} + +**Referenced by**: +- Index.md: `#### Session Interaction` +- Shared.md: `#### Session Response Rendering` +- API_Task.md: `### copilot/task/{task-id}/live/{token}` +- API_Job.md: `### copilot/job/{job-id}/live/{token}` +- Jobs.md: `### jobTracking.html` + +This is a query to wait for responses back for this session. +Each session generates many responses, storing in a list. +When the api comes, the current reading position needs to look up for the `token`, if `token` is not used with this `session-id`, create a record and put 0. + +Each query returns **all** available responses from the current position to the end of the list in a single batch, and the position advances past all returned responses. +This ensures that when multiple tokens poll the same entity, they drain buffered responses instantly and converge to the same pending position, so all tokens receive new responses simultaneously. + +If there is no response, do not reply the API. If there is no response after 5 seconds, send back a `HttpRequestTimeout`. +Be aware of that api requests and session responses could happen in any order. + +This api does not support parallel calling on the (`session-id`, `token`). +If a call with a (`session-id`, `token`) is pending, +the second call with the same (`session-id`, `token`) should return `ParallelCallNotSupported`. + +#### Optimization + +When `onEndReasinong` is pushed to the responses list: +- Focus on the same id, do not touch others. +- Search from the end, stop at the `onStartReasoning`. +- Remove all `onReasoning` for that id. +- This would affect cached token positions. It is easy to fix by counting how many removed responses has been read. + +When `onReasoning` is pushed to the responses list: +- Focus on the same id, do not touch others. +- Search from the end, stop at the `onStartReasoning`. +- For all `onReasoning` whose position >= `largest token reading position`, merge them to one. Concat all `delta` together without delimiter. +- For all `onReasoning` whose position < `smallest token reading position`, merge them to one. Concat all `delta` together without delimiter. + - This would affect cached token positions. It is easy to fix by counting how many removed responses has been read. + +Applies above optimization to `onStartMessage`, `onMessage`, `onEndMessage`. + +When a new response pushes to the responses list, causing a pending request to wake up: +- The time of the last live api responding (not the requesting) needs to be recorded for the `token` and uses it as below. +- Now the pending request will not return `HttpRequestTimeout`, but it will wait until 5 seconds after the last call. + - If it is already longer than 5 seconds, send new responses immediately. + - Whenever a live api request is responded, update the time for that `token`. + +#### API Schema + +Returns in this schema when responses are available: + +```typescript +{ + responses: LiveResponse[] +} +``` + +Returns in this schema if any error happens + +```typescript +{ + error: "SessionNotFound" | "SessionClosed" | "HttpRequestTimeout" | "ParallelCallNotSupported" +} +``` + +**TEST-NOTE-BEGIN** +Can't trigger "HttpRequestTimeout" stably in unit test so it is not covered. +It requires the underlying copilot agent to not generate any response for 5 seconds, +which is almost impossible. +If the server is launched with `--test` to enter a test mode, the 1 minute count down becomes 5 seconds, that enable you to test the logic efficiently. +**TEST-NOTE-END** + +#### Session Life-cycle and Visibility + +When the session is running, its life-cycle remains. +When the session is finished, it counts down for 1 minute. +When count down ends, the life-cycle ends. + +If a `token` is used for the session during its life-cycle, the session will be forever visible to the token, until all responses are drained: +- The `token` will be able to access all responses generated by this session in its life-cycle. + - The position 0 means the first response after the session starts, not the first response after a new token joined. +- When a session is closed, but responses for this session is not drained by the API yet, the API still responses. +- When there is no more response and the session already stopped, it returns `SessionClosed`. +- After `SessionClosed`, the `session-id` will be no longer available for this api, future calls returns `SessionNotFound`. +- Even the client received notification that a session stops from another API, it is recommended to keep reading until `SessionClosed` returns. + +If the `session-id` does not exist, or a `token` is used after the session's life-cycle: +- the session is not visible and it should return `SessionNotFound`. + +#### Session Response Storages + +Tips for implementation: +- All responses will be adding to a list. +- A map from token to one reading position and one pending api calls is maintained. +- The 1 minute counting down do not need to involve a timer, it could be implemented by: + - Before the session ends, store `undefined` to the `count down begin`. + - When a new `token` comes: + - If `count down begin` is `undefined`, or it is defined but still in 1 minute, add this token to the map. + - Otherwise this session is not visible to the token, reject the call with `SessionNotFound`. + - After replying `SessionClosed` for a token, the token is removed from the map. + - After the map is empty, the session can be deleted. + - `ICopilotSession.stop` should be called when the session is no longer working. + - `session can be deleted` here only means the data structure maintained for token accessing. + +**TODO**: If a token is used but the client suddenly ends, since `SessionClosed` can never be sent to this client, memory leaks happens. But let us ignore this issue yet, it will be addressed in the future. + +#### Exception Handling + +An element in the `responses` array with this schema represents an exception thrown from inside the session + +```typescript +{ + sessionError: string +} +``` + +#### Response Format + +Each element in the `responses` array maps to a method in `ICopilotSessionCallbacks` in `src/copilotSession.ts` in this schema + +```typescript +{ + callback: string, + argument1: ..., + ... +} +``` + +For example, when `onReasoning(reasoningId: string, delta: string): void;` is called, it returns + +```typescript +{ + callback: "onReasoning", + reasoningId: string, + delta: string +} +``` + +When running a task, any generated prompts will be reported in this schema + +```typescript +{ + callback: "onGeneratedUserPrompt", + prompt: string +} +``` diff --git a/.github/Agent/prompts/spec/CopilotPortal/API_Task.md b/.github/Agent/prompts/spec/CopilotPortal/API_Task.md new file mode 100644 index 00000000..51659ca6 --- /dev/null +++ b/.github/Agent/prompts/spec/CopilotPortal/API_Task.md @@ -0,0 +1,239 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `src/sharedApi.ts` +- `src/taskApi.ts` +- `src/index.ts` + +Data structures about jobs and tasks are in `src/jobsDef.ts`. +Its spec is in `JobsData.md`. + +If an api requires `Entry`: +- The first argument of the api helper function must be `entry: Entry`. +- `index.ts` will call `ensureInstalledEntry` for this argument. + +**TEST-NOTE-BEGIN** +`yarn launch-for-test` will be used for unit testing therefore you have a chance to specify your own entry file. +DO NOT use the default entry for testing. + +The free model "gpt-5-mini" must be used in every `Entry.models`. +If the model is no longer available, choose a new one and update both spec and custom entry JSON files. +**TEST-NOTE-END** + +## Helpers (taskApi.ts) ----------------------------------------------------------------------------------------------------------------------------- + +All helper functions and types are exported and API implementations should use them. + +### SESSION_CRASH_PREFIX + +**Referenced by**: +- JobsData.md: `## Running Tasks` + +`const SESSION_CRASH_PREFIX = "The session crashed, please redo and here is the last request:\n";` +- Internal constant (not exported). Prefix added to prompts when resending after a session crash. + +### sendMonitoredPrompt (crash retry) + +**Referenced by**: +- API_Task.md: `### copilot/task/start/{task-name}/session/{session-id}`, `### copilot/task/{task-id}/stop`, `### copilot/task/{task-id}/live/{token}` +- API_Job.md: `### copilot/job/start/{job-name}`, `### copilot/job/{job-id}/stop`, `### copilot/job/{job-id}/live/{token}` +- JobsData.md: `## Running Tasks`, `## Running Jobs`, `### Task.availability`, `### Task.criteria` + +A private method on the `CopilotTaskImpl` class used by both task execution and condition evaluation. +Sends a prompt to a session, retrying up to 5 consecutive crashes (except in borrowing mode where retry is 1). +On retry, prepends `SESSION_CRASH_PREFIX` to the prompt. +Also pushes `onGeneratedUserPrompt` to the driving session's response queue. + +### errorToDetailedString + +`errorToDetailedString(err: unknown): string;` +- Convert any error into a detailed JSON string representation including name, message, stack, and cause. + +```typescript +interface ICopilotTask { + readonly drivingSession: ICopilotSession; + readonly status: "Executing" | "Succeeded" | "Failed"; + readonly crashError?: any; + // stop all running sessions, no further callback issues. + stop(): void; +} + +interface ICopilotTaskCallback { + // Called when this task succeeded + taskSucceeded(): void; + // Called when this task failed + taskFailed(): void; + // Called when the driving session finishes a test or makes a decision + taskDecision(reason: string): void; + // This callback is unavailable if it is running with borrowing session mode + // Called when a task session started, with its session id + // When this session is created as a driving session, the isDrivingSession argument is true. + taskSessionStarted(taskSession: ICopilotSession, taskId: string, isDrivingSession: boolean): void; + // This callback is unavailable if it is running with borrowing session mode + // Called when a task session stopped, with its session id + // If the task succeeded, the succeeded argument is true + taskSessionStopped(taskSession: ICopilotSession, taskId: string, succeeded: boolean): void; +} + +async function startTask( + entry: Entry, + taskName: string, + userInput: string, + drivingSession: ICopilotSession | undefined, + ignorePrerequisiteCheck: boolean, + callback: ICopilotTaskCallback, + taskModelIdOverride: string | undefined, + workingDirectory: string | undefined, + exceptionHandler: (err: any) => void +): Promise +``` +- Start a task. +- When `drivingSession` is defined: + - the task is in borrowing session mode +- When `drivingSession` is not defined: + - `startTask` needs to create and maintain all sessions it needs. +- The `exceptionHandler` is called if the task execution throws an unhandled exception. + +## API (taskApi.ts) --------------------------------------------------------------------------------------------------------------------------------- + +All restful read arguments from the path and returns a JSON document. + +All title names below represents http:/*:port/api/TITLE + +Task hosting is implemented by `src/taskApi.ts`. + +### copilot/task + +**Referenced by**: +- Index.md: `#### Request Part` + +List all tasks passed to `installJobsEntry` in this schema: +```typescript +{ + tasks: { + name: string; + requireUserInput: boolean; + }[] +} +``` + +### copilot/task/start/{task-name}/session/{session-id} + +**Referenced by**: +- Index.md: `#### Request Part` + +**TEST-NOTE-BEGIN** +Besides of testing API failures, it is important to make sure task running works. + +Create test cases for running a task, focused on the `criteria` stuff. +Test every fields in `criteria` and ensure: +- If the test succeeded, retry won't happen. +- If the task failed, retry happens. +- Retry has a budget limit. +- Whenever the task succeeded or failed, the live api responses correctly. +- Retrying should be observable from the live api. + +Skip testing against session crashes scenarios because it is difficult to make it crash. + +It is able to make up a failed test by: +- Does nothing +- In `criteria` specify `toolExecuted`, since the task does nothing, this will never satisfies. +- Set a retry budget limit to 0 so no retrying should happen. +- Therefore it fails because of not being able to pass the criteria check. +**TEST-NOTE-END** + +The body will be user input. + +Start a new task and return in this schema. +borrowing session mode is forced with an existing session id. +Prerequisite checking is skipped. + +After the task finishes, it stops automatically, the task id will be unavailable immediately. +Keep the session alive. + +```typescript +{ + taskId: string; +} +``` + +or when error happens: + +```typescript +{ + error: "SessionNotFound" +} +``` + +### copilot/task/{task-id}/stop + +The API will ignore the action and return `TaskCannotClose` if the task is started with borrowing session mode. + +A task will automatically stops when finishes, +this api forced the task to stop. + +Stop the task and return in this schema. + +```typescript +{ + result: "Closed" +} +``` + +or when error happens: + +```typescript +{ + error: "TaskNotFound" | "TaskCannotClose" +} +``` + +### copilot/task/{task-id}/live/{token} + +**Referenced by**: +- Jobs.md: `### jobTracking.html` + +It works like `copilot/session/{session-id}/live/{token}` but it reacts to `ICopilotTaskCallback`. +They should be implemented in the same way, but only respond in schemas mentioned below. +`token` can be obtained by `/token` but no checking needs to perform. + +Returns in this schema when responses are available (batched, same as session live API): + +```typescript +{ + responses: LiveResponse[] +} +``` + +Returns in this schema if any error happens + +```typescript +{ + error: "TaskNotFound" | "TaskClosed" | "HttpRequestTimeout" | "ParallelCallNotSupported" +} +``` + +Special `TaskNotFound` and `TaskClosed` handling for this API: +- Works in the same way as `SessionNotFound` and `SessionClosed` in `copilot/session/{session-id}/live/{token}`. + +**TEST-NOTE-BEGIN** +Can't trigger "HttpRequestTimeout" stably in unit test so it is not covered. +It requires the underlying copilot agent to not generate any response for 5 seconds, +which is almost impossible. +**TEST-NOTE-END** + +An element in the `responses` array with this schema represents an exception thrown from inside the session + +```typescript +{ + taskError: string +} +``` + +Each element in the `responses` array maps to a method in `ICopilotTaskCallback` in `src/taskApi.ts`. + +When a task is created by a job's `executeWork`, the `taskSessionStarted` response additionally includes `sessionId` (string) and `isDriving` (boolean) fields so the frontend can poll `copilot/session/{session-id}/live/{token}` and distinguish between driving and task sessions. diff --git a/.github/Agent/prompts/spec/CopilotPortal/Index.md b/.github/Agent/prompts/spec/CopilotPortal/Index.md new file mode 100644 index 00000000..7ea7659d --- /dev/null +++ b/.github/Agent/prompts/spec/CopilotPortal/Index.md @@ -0,0 +1,124 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `assets` + - `index.css` + - `index.js` + - `index.html` + +### index.css + +Put index.html specific css file in index.css. + +### index.js + +Put index.html specific javascript file in index.js. + +### index.html + +#### Starting a Copilot Session + +**Referenced by**: +- Jobs.md: `### jobs.html` + +When the webpage is loaded, it renders a UI in the middle to let me input: +- Model. A combo box with contents retrieved from `api/copilot/models`. + - Items must be sorted. + - Names instead of ids are displayed, but be aware of that other API needs the id. + - Default to the model whose id is "gpt-5.2" +- Working Directory. A text box receiving an absolute full path. + - When the url is `index.html?project=XXX`, the text box defaults to `REPO-ROOT\..\XXX` + - When there is no `project` argument, it becomes `REPO-ROOT`. + - `REPO-ROOT` is the root folder of the repo that the source code of this project is in (no hardcoding). + +There are a row of buttons with proper margin: +- On the very left: "New Job", "Refresh" +- On the very right: "Start" + +Below there is a list, displaying all running job's name, status, time. +The list only refresh when the webpage is loaded or the "Refresh" button is clicked. +It can be listed by `copilot/job/running`. +At the very left of each item, there is a "View" button. It starts `/jobTracking.html` to inspect into the job. + +The list must be in the same width with the above part. + +##### Start Button + +When I hit the "Start" button, the UI above disappears and show the session UI. +Send `api/copilot/session/start/{model-id}` to get the session id. + +Only after the model list is loaded, "Start" is enabled. + +##### Jobs Button + +**Referenced by**: +- Jobs.md: `### jobs.html` + +When I hit the "New Job" button, it jumps to `/jobs.html`. +The selected model is ignored, but the working directory should be brought to `/jobs.html`. + +#### Session Interaction + +The agent UI has two part. + +The upper part (session part) renders what the session replies. +The lower part (request part) renders a text box to send my request to the session. +The session part and the request part should always fill the whole webpage. +Between two parts there is a bar to drag vertically to adjust the height of the request part which defaults to 300px. + +After the UI is loaded, +call `api/token` for a token, +the page must keep sending `api/copilot/session/{session-id}/live/{token}` to the server sequentially (aka not parallelly). +When a timeout happens, resend the api. +When it returns any response, process it and still keep sending the api. +Whenever `ICopilotSessionCallbacks::METHOD` is mentioned, it means a response from this api. + +There are additional callbacks: +- `onGeneratedUserPrompt`: Create a "User" message block with `title` set to `Task`, pretending that the user is talking. + +After "Stop Server" or "Close Session" is pressed, responses from this api will be ignored and no more such api is sending. + +#### Session Part + +The session part div is passed to a `SessionResponseRenderer` (from `sessionResponse.js`) which handles all rendering within it. +See `Shared.md` for `SessionResponseRenderer` specification. + +Live polling callbacks are forwarded to `sessionRenderer.processCallback(data)`. +When its return value is `onIdle`, the send button is re-enabled. + +When not running a task, any user request should call `sessionRenderer.addUserMessage(text)` to create a "User" message block. +Call `sessionRenderer.setAwaiting(true)` when waiting for responses, and `sessionRenderer.setAwaiting(false)` when done (sync with "Send" button's enabled state). + +#### Request Part + +**Referenced by**: +- Jobs.md: `### Matrix Part` + +At the very top there is a label "Choose a Task: " followed by a combo box in a proper weight, listing all tasks. +Keep the visual looks similar and good margin between controls. +The first item is "(none)". When it is selected, "Send" will talk to the session directly instead of starting a task. +If a task is selected, the same session will be reused to start a task. + +It is a multiline text box. I can type any text, and press CTRL+ENTER to send the request. + +There is a "Send" button at the right bottom corner of the text box. +It does the same thing as pressing CTRL+ENTER. +When the button is disabled, pressing CTRL+ENTER should also does nothing. +`api/copilot/session/{session-id}/query` is used here. + +User request should generate a "User" message block, append the request and immediately complete it. + +When a request is sent, the button is disabled. +When `ICopilotSessionCallbacks::onIdle` triggers, it is enabled again. + +There is a "Stop Server" and "Close Session" button (in the mentioning order) at the left bottom corner of the text box. +When "Close Session" is clicked: +- Ends the session with `api/copilot/session/{session-id}/stop`. +- Do whatever needs for finishing. +- Try to close the current webpage window or tab. If the browser blocks it (e.g. Chrome blocks `window.close()` for tabs not opened by script), replace the page content with a message indicating the session has ended and the tab may be closed manually. +When "Stop Server" is clicked: +- It does what "Close Session" does, with an extra `api/stop` to stop the server before attempting to close the webpage. diff --git a/.github/Agent/prompts/spec/CopilotPortal/Jobs.md b/.github/Agent/prompts/spec/CopilotPortal/Jobs.md new file mode 100644 index 00000000..e375adda --- /dev/null +++ b/.github/Agent/prompts/spec/CopilotPortal/Jobs.md @@ -0,0 +1,254 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `assets` + - `jobs.css` + - `jobs.js` + - `jobs.html` + - `jobTracking.css` + - `jobTracking.js` + - `jobTracking.html` + - `flowChartMermaid.js` + +### jobs.css + +Put jobs.html specific css file in jobs.css. + +### jobs.js + +Put jobs.html specific javascript file in jobs.js. + +### jobs.html + +This page should only be opened by `/index.html`'s "Jobs" button in order to obtain the working directory. +If it is directly loaded, it should redirect itself to `/index.html`. +To tell this, find the `wb` argument in the url passing from `/index.html`. + +Call `api/copilot/job` to obtain jobs definition. +- `grid` defines the `matrix part` of the UI. +- `jobs` offers details for each job in order to render the tracking UI. + +The webpage is split into two parts: +- The left part is `matrix part`. +- The right part is `user input part`. +- The left and right parts should always fill the whole webpage. +- Between two parts there is a bar to drag vertically to adjust the width of the right part which defaults to 800. + +The look-and-feel must be similar to `/index.html`, but DO NOT share any css file. + +### Matrix Part + +It renders a large table of buttons according to `grid`. +The first row is a title "Available Jobs", followed by a button "Stop Server" at the very right doing exactly what the button in `/index.html` does. +The first column shows `keyword`. +All other columns are for `grid[index].jobs`, `name` will be the text of the button. +`undefined` in any `grid[index].jobs` element renders an empty cell. + +If any cell has no button, leave it blank. +The table is supposed to fill all `matrix part` but leave margins to the border and between buttons. +The table is also rendered with light color lines. + +Font size should be large enough to reduce blanks, prepare to fill about 1000x1000 or a even larger space. The complete content can be read in `jobsData.ts`, it helps to guess a font size as it will but rarely changes. + +Besides automate buttons, other buttons renders as buttons but it works like radio buttons: +- Clicking a job button renders an exclusive selected state. + - Its `jobName` (not the `name` in the button text) becomes `selectedJobName`. + - The "Start Job" button is enabled. + - Only when `requireUserInput` is true, the text box is enabled +- Clicking a selected job button unselect it + - The "Start Job" button is disabled. + - The text box is disabled. + +#### Actions of Automate Button + +(to be editing...) + +### User Input Part + +There is a text box fills the page. Disabled by default. + +At the bottom there are buttons aligned to the right: +- "Start Job: ${selectedJobName}" or "Job Not Selected". Disabled by default. +- "Preview". + +#### Clicking Start Job Button + +When I hit the "Start Job" button, call `copilot/job/start/{job-name}`. +When a job id is returned, it jumps to `/jobTracking.html` in a new window. +The selected job directory and the job id should be brought to `/jobTracking.html`. +No need to bring other information. + +#### Clicking Preview Button + +Sync "Preview"'s enability to "Start Job". +Call `/jobTracking.html` without starting the job and not passing the job id. +This triggers the preview mode. + +(to be editing...) + +### jobTracking.css + +Put jobTracking.html specific css file in jobTracking.css. + +### jobTracking.js + +Put jobTracking.html specific javascript file in jobTracking.js. + +### jobTracking.html + +This page should only be opened by `/jobs.html`'s "Start Job" button in order to obtain the working directory. +If it is directly loaded, it should redirect itself to `/index.html`. +To tell this, find the `jobName` and `jobId` argument in the url passing from `/jobs.html`. + +Call `api/copilot/job` to obtain the specified job's definition. +`jobs[jobName]` and `chart[jobName]` is used to render the flow chart. + +#### When jobId argument presents + +Call `api/copilot/job/{job-id}/live/{token}`, `api/copilot/task/{task-id}/live/{token}` and `copilot/session/{session-id}/live/{token}` to update the running status of the job: +- job live api notifies when a new task is started, task live api notifies when a new session is started. +- Drain all responses from live apis, no more issue until `(Session|Task|Job)(Closed|NotFound)` returns. +- On receiving `ICopilotTaskCallback.taskDecision` + - Create a "User" message block with `title` set to `TaskDecision`, copying the `reason`, put it in the driving session. + +#### When jobId argument not presents + +The webpage preview the job only, no tracking is performed. +The "Stop Job" button disappears. +The job status becomes "JOB: PREVIEW". +Clicking `ChartNode` does nothing. + +#### Layout + +The webpage is split into two parts: +- The left part is `job part`. +- The right part is `session response part`. +- The left and right parts should always fill the whole webpage. +- Between two parts there is a bar to drag vertically to adjust the width of the right part which defaults to 800. + +The look-and-feel must be similar to `/index.html`, but DO NOT share any css file. + +### Job Part + +You can find the `Job` definition in `jobsDef.ts`. +`Job.work` is a simple control flow AST. +Render it like a flow chart expanding to fill the whole `job part`. + +#### Flow Chart Rendering Note + +#### Tracking Job Status + +When the webpage is loaded, call `api/copilot/job/{job-id}/status` and update job status and each `ChartNode`. +The job could have been running for a long time. But the job live api will still response all history. +When a task or session of notified started, it could already have been stopped, ignore the error when task/session api says the entity does not exist. + +Call `api/token` for a token, and it can be used in any live api call issued in thie webpage. +When the status of a task running is changed, +update the rendering of a `ChartNode` of which `hint` is `TaskNode` or `CondNode`: +- If a symbol attaches to a node, it is at the center of the left border but outside. + - Use emoji if possible. + - If it is technically impossible to touch the left border you can choose another place. +- When a task begins running, attach a green triangle. +- When a task finishes running, the triangle is removed. + - If it succeeds, a green tick replaces the triangle. + - If it fails, a red cross replaces the triangle. +- There could be loops in the flow chart, which means a task could starts and stops multiple times. + +When a task runs, track all status of sessions in this task: +- The driving session names `Driving`. +- Multiple occurrences of the task session names beginning from `Attempt #1`. + - If a task is being inspected, when a session starts, `session response part` should display the new tab header, but do not switch to the new tab. + - Responses in a tab should keep updating if new data comes. +- Whenever the task is being inspected or not, status should keep recording. The user will inspect here and there, data should persist. + - For convenience, whenever a session starts, a `Session Response Rendering` from `Shared.md` could be created and adding message blocks. + - When the task is being inspected, display it, otherwise removes it from the DOM. +- When a task begins **AGAIN**, cached data will be deleted and replaced with status of the latest run. + +The `Flow Chart Rendering` should keep centered horizontally and vertically. +At the very top of the `job part`: +- At the left there is a big label showing the job that: + - `JOB: RUNNING` + - `JOB: SUCCEEDED` + - `JOB: FAILED` +- At the right there is a button in the same label size "Stop Job" + - It is enabled when the job is running. + - Clicking the button should call `api/copilot/job/{job-id}/stop` and the big label becomes `JOB: CANCELED`. + +#### Flow Chart Rendering + +**TEST-NOTE-BEGIN** +No need to create unit test to assert the chart is in a correct layout. +Ensure every `TaskWork` has a `ChartNode` with `TaskNode` or `CondNode` hint. +**TEST-NOTE-END** + +The `api/copilot/job` has an extra `chart` node which is a `ChartGraph`. +It is already a generated flow chart but has no layout information. + +Each `ChartNode` is a node in the flow chart, and each hint maps to a graph: +- `TaskNode`: A blue rectangle with the task id, the text would be the `TaskWork` with that `workIdInJob`. +- `CondNode`: A yellow hexagon with the task id, the text would be the `TaskWork` with that `workIdInJob`. +- `ParBegin`, `ParEnd`: A small black rectangle bar. +- `AltEnd`: A small pink rectangle bar. +- `CondBegin`: A small yellow rectangle bar. +- `CondEnd`: A small yellow diamond. +- `LoopEnd`: A small gray circle. + +For `TaskNode` and `CondNode`: + - The text would be the `TaskWork` with that `workIdInJob`. + - If `modelOverride` is specified, render it like `task (model)`. + +Each graph must have a border, and the background color would be lighter, except the black rectangle bar which has the same border and background color. + +There will be multiple arrows connecting between nodes: +- `ChartArrow.to` is the target `ChartNode` with that `id`. +- When `ChartArrow.loopBack` is true, it hints an arrow pointing upwards. All others should be downwards. +- `ChartArrow.label` is the label attached to the arrow. + +Arrows would be gray. + +#### Rendering with Mermaid + +Implementation stores in: +- `flowChartMermaid.js` + +No separate CSS file is needed; node styles are embedded as inline Mermaid `style` directives in the generated definition. + +Use [Mermaid.js](https://mermaid.js.org/) (loaded from CDN) for declarative flowchart rendering: +- Initialize Mermaid with `startOnLoad: false` so rendering is controlled programmatically. +- Build a Mermaid `graph TD` definition string from the `ChartGraph`. +- Each `ChartNode` becomes a Mermaid node with shape syntax matching its hint (rectangles, hexagons `{{}}`, circles `(())`). +- Each `ChartArrow` becomes a Mermaid edge with optional label. +- Per-node inline `style` directives set fill, stroke, and text color. +- Call `mermaid.render("mermaid-chart", definition)` to produce an SVG, then insert it into the container. +- The SVG viewBox must have enough padding (at least 24px) so that status indicator emojis on leftmost nodes are not clipped. + +#### Ctrl+Scroll Zoom + +When the user holds **Ctrl** and scrolls the mouse wheel over the `#chart-container`: +- The flow chart SVG scales up (scroll up) or down (scroll down) in discrete steps of 0.1. +- Zoom range is clamped between 0.2x and 3x, defaulting to 1x. +- CSS `transform: scale(...)` with `transform-origin: top left` is applied to the SVG element. +- The default browser scroll/zoom behavior is suppressed (`preventDefault`). + +#### Interaction with `ChartNode` which has a `TaskNode` or `CondNode` hint + +Clicking it select (exclusive) or unselect the text: +- When it becomes selected, the task is being inspected, `session response part` should display this task. +- When it becomes unselected, the task is not being inspected. `session response part` should restore. +- The border of the node becomes obviously thicker when selected. + +### Session Response Part + +When no task is being inspected, print `JSON.stringify(jobToRender and chartToRender, undefined, 4)` inside a `
` element.
+
+When a task is being inspected:
+- It becomes a tab control.
+- Each tab is a session, tab headers are names of sessions.
+  - The first tab will always be `Driving` and all driving sessions come to here.
+  - Each task session has its own tab.
+- Clicking a tab shows responses from a session using `Session Response Rendering` from `Shared.md`.
+- When the selected `ChartNode` is restarted, tabs should be cleared before adding new sessions.
diff --git a/.github/Agent/prompts/spec/CopilotPortal/JobsData.md b/.github/Agent/prompts/spec/CopilotPortal/JobsData.md
new file mode 100644
index 00000000..91b97fc1
--- /dev/null
+++ b/.github/Agent/prompts/spec/CopilotPortal/JobsData.md
@@ -0,0 +1,330 @@
+# Specification
+
+Root folder of the project is `REPO-ROOT/.github/Agent`.
+Read `README.md` to understand the whole picture of the project as well as specification organizations.
+
+## Related Files
+
+- `src/jobsDef.ts`
+- `src/jobsChart.ts`
+- `src/jobsData.ts`
+- `src/jobsApi.ts`
+
+## Functions
+
+### expandPromptStatic
+
+**Referenced by**:
+- JobsData.md: `### expandPromptDynamic`, `### validateEntry`
+
+A function convert from `Prompt` to `Prompt` with only one string.
+`Prompt` is a string array, the function should join them with LF character.
+In each string there might be variables.
+A variable is $ followed by one or multiple words connected with hyphens.
+When a variable is in `entry.promptVariables`, replace the variable with its values.
+When a variable is in `runtimeVariables`, keep them.
+When it fails to look up the value, report an error `${codePath}: Cannot find prompt variable: ${variableName}.`.
+Be aware of that, the value is still a `Prompt`, so it is recursive.
+
+When calls `expandPromptStatic` recursively for a resolved prompt variable,
+the `codePath` becomes `${codePath}/$${variableName}`.
+
+Report an error if `prompt` is empty.
+
+### expandPromptDynamic
+
+**Referenced by**:
+- JobsData.md: `## Running Tasks`
+
+It works like `expandPromptStatic`, but assert `prompt` has exactly one item.
+Look up all `runtimeVariables` in `values` argument.
+Be aware of that, not all variable has an value assigned.
+When it fails to look up the value, use `` as its value.
+
+### validateEntry
+
+**Referenced by**:
+- API_Task.md: `### copilot/test/installJobsEntry`
+
+Perform all verifications, verify and update all prompts with `expandPromptStatic`:
+- entry.tasks[name].prompt
+- entry.tasks[name].availability.condition (run extra verification)
+- entry.tasks[name].criteria.condition (run extra verification)
+- entry.tasks[name].criteria.failureAction.additionalPrompt
+
+When extra verification is needed,
+`expandPromptStatic`'s `requiresBooleanTool` will be set to true,
+it verifies that `job_boolean_true` or `job_boolean_false` must be mentioned in the expanded prompt.
+
+Here are all checks that `validateEntry` needs to do:
+- `entry.models.driving`:
+  - Should exist.
+- `entry.drivingSessionRetries`:
+  - Contains at least one item and the first modelId should equal to `entry.models.driving`.
+- `entry.grid[rowIndex].jobs[columnIndex].jobName`:
+  - Must be in keys of `entry.jobs`.
+- `entry.tasks[name].model.category`;
+  - Must be in fields of `entry.models`.
+- `entry.tasks[name].requireUserInput`:
+  - If it is true, its evaluated `prompt` should use `$user-input`, otherwise should not use.
+- `entry.tasks[name].criteria.toolExecuted[index]`:
+  - Must be in `availableTools`.
+- Any `TaskWork` in `entry.jobs[name]`, needs to inspect recursively:
+  - `TaskWork.taskId` must be in `entry.tasks`.
+  - `TaskWork.modelOverride.category` must be in fields of `entry.models`.
+  - `TaskWork.modelOverride` must be defined if that task has no specified model.
+- Any `SequentialWork` and `ParallelWork`:
+  - `works` should have at least one element.
+- `entry.jobs[name].requireUserInput`.
+  - Find out if a job requires user input by inspecting all `Task` record referenced by any `TaskWork` in this job.
+  - If any task `requireUserInput`, then the job `requireUserInput`, otherwise the job does not `requireUserInput`.
+  - If `Job.requireUserInput` is defined, it should reflect the correct value.
+  - If `requireUserInput` is undefined, fill it.
+  - After `validateEntry` finishes, all `Job.requireUserInput` should be filled.
+- `entry.jobs[name].work`:
+  - Simplifies the `work` tree:
+    - Expand nested `SequentialWork`, that a `SequentialWork` directly in another `SequentialWork` should be flattened. They could be multiple level.
+    - Expand nested `ParallelWork`, that a `ParallelWork` directly in another `ParallelWork` should be flattened. They could be multiple level.
+
+If any validation runs directly in this function fails:
+- The error code path will be `codePath` appended by the javascript expression without any variable.
+  - For example, if `entry.tasks[name].model` fails with task `scrum-problem-task`, the error code path becomes `${codePath}entry.tasks["scrum-problem-task"].model`.
+- It must throws an error like "${errorCodePath}: REASON".
+- The functions must also use the error code path for any `Prompt` expression to call `expandPromptStatic`.
+
+## Running Tasks
+
+**Referenced by**:
+- API_Task.md: `### sendMonitoredPrompt (crash retry)`
+- JobsData.md: `### TaskWork`
+
+A task is represented by type `Task`.
+
+A task can be running with `borrowing session mode` or `managed session mode`.
+
+### Borrowing Session Mode
+
+Every prompt will be running in the given session.
+If the session crashes, fail the task immediately.
+
+### Managed Session Mode
+
+Before starting a task, it needs to decide if the task is running in single model or multiple models.
+Single model option will be enabled when one of the following conditions satisfies:
+- `Task.criteria` is undefined.
+- `Task.criteria.runConditionInSameSession` is undefined or it is true.
+
+Every session is created and managed by the task.
+No matter single model or multiple models is selected:
+- If a session crashes, new session must be created to replace it.
+- If executing the same prompt results in consecutive crashes and eventually draining all retry budget, fail the task immediately.
+- Add `SESSION_CRASH_PREFIX` (const from `taskApi.ts`: `"The session crashed, please redo and here is the last request:\n"`) before the prompt when resend.
+- The crash retry logic is implemented in the private `sendMonitoredPrompt` method on `CopilotTaskImpl` in `taskApi.ts`.
+- The exception cannot be consumed silently, and every exception should be reported by `ICopilotTaskCallback.taskDecision`.
+
+#### Retry Budget
+
+Task session retry budget is defined in `Task.criteria.failureAction`.
+Driving session retry budget is defined in `entry.drivingSessionRetries`.
+When en single mode, retry budgets are still separately applying, depending on if the session is running driving session work (availability/criteria check) or task session work.
+
+Driving session retries in this way:
+- Ensure `entry.model.driving === entry.drivingSessionRetries[0].modelId`. This should have been ensured by `validateEntry` but assert in here again.
+  - This should be done in `startTask` and therefore assume it always satisfies.
+- Open a `number[]` array copying all `entry.drivingSessionRetries[index].retries`.
+- For each `entry.drivingSessionRetries[index]`, if `retryBudget[index]` > 0, decreases it and perform the retry.
+  - Retry with the appriopriate prompt for each using `modelId`.
+  - This mean try all candidate models in each round, but pay attention to the retry budget as they are different per model.
+- Keep doing until `retryBudget` contains only zeros, which means all retyr budget trained.
+- Budget refreshes for every driving mission (which the current implementation is already doing but it follows the old retry budget design).
+
+#### Managed Session Mode (single model)
+
+A session will be created, and it serves both a driving session and a task session.
+`Entry.models[Task.model]` will be used. When `Task.model` is undefined, the model id should be assigned from `startTask`.
+
+#### Managed Session Mode (multiple models)
+
+Session are created only when needed, and it is closed when its mission finishes.
+
+The mission of a driving session is:
+- Perform one `Task.availability.condition` check.
+- Perform one `Task.criteria.condition` check.
+- Finishing the check closes the driving session
+
+The mission of a task session is:
+- Perform one `Task.prompt`.
+- Finishing one `Task.prompt` closes the task session
+
+The driving session uses `Entry.models.driving`.
+The task session uses `Entry.models[Task.model]`. When `Task.model` is undefined, the model id should be assigned.
+
+#### Preprocess Prompt
+
+Before sending any prompt to the driving or task session,
+`expandPromptDynamic` will be called to apply runtime variables.
+This function must be called every time a prompt is sent to the session,
+because runtime variables could change.
+
+### Tools and Runtime Variables
+
+**Referenced by**:
+- JobsData.md: `### expandPromptDynamic`, `### Task.availability`, `### Task.criteria`, `### validateEntry`
+
+`$user-input` will be passed from the user directly.
+`$task-model` will be the model name selected to run the task session. It is not the category, it is the actual model name.
+
+The following tools could be called in the driving or task session.
+- When `job_prepare_document` is called, its argument becomes `$reported-document`. If there is multiple line, only keep the first line and trim and space characters before and after it.
+- When the `job_boolean_true` tool is called, the condition satisfies.
+  - The argument will be assigned to the `$reported-true-reason` runtime variable.
+  - `$reported-false-reason` will be deleted.
+- When the `job_boolean_false` tool is called, the condition fails.
+  - The argument will be assigned to the `$reported-false-reason` runtime variable.
+  - `$reported-true-reason` will be deleted.
+
+### Task.availability
+
+**Referenced by**:
+- JobsData.md: `### TaskWork`, `### validateEntry`
+- API_Task.md: `### copilot/task/start/{task-name}/session/{session-id}`
+
+If `Task.availability` is not defined,
+there will be no prerequisite checking,
+the task just run.
+
+All conditions must satisfy at the same time to run the task:
+- When `Task.availability.condition` is defined, the condition must satisfy.
+  - The driving session will run the prompt.
+  - The condition satisfies when the `job_boolean_true` is called in this round of driving session response.
+
+otherwise the `job_prerequisite_failed` tool will be called in the driving session,
+indicating the task fails.
+
+### Task.criteria
+
+**Referenced by**:
+- JobsData.md: `### TaskWork`, `### validateEntry`
+- API_Task.md: `### copilot/task/start/{task-name}/session/{session-id}`
+
+If `Task.criteria` is not defined,
+there will be no criteria checking,
+the task is treat as succeeded.
+
+All conditions must satisfy to indicate that the task succeeded:
+- When `Task.criteria.toolExecuted` is defined, all tools in the list should have been executed in the last round of task session response.
+  - When retrying the task due to `toolExecuted`, append `## Required Tool Not Called: {tool names ...}`.
+- When `Task.criteria.condition` is defined:
+  - The driving session will run the prompt.
+  - The condition satisfies when the `job_boolean_true` is called in this round of driving session response.
+
+The task should react to `Task.criteria.failureAction` when task execution does not satisfy the condition:
+- Retry at most `retryTimes` times.
+- Send the original prompt, extra prompt is appended if:
+  - The criteria test fails due to `toolExecuted`: append `## Required Tool Not Called: {tool names ...}`.
+  - `additionalPrompt` defines: append `"## You accidentally Stopped"` followed by the `additionalPrompt`.
+
+### Calling ICopilotTaskCallback.taskDecision
+
+In above sessions there are a lot of thing happenes in the driving session. A reason should be provided to `taskDecision`, including but not limited to:
+- The availability test passed.
+- The availability test failed with details.
+- The criteria condition test passed.
+- The criteria condition test failed with details.
+- Starting a retry with retry number.
+- Retry budget drained because of availability or criteria failure.
+- Retry budget drained because of crashing.
+  - These two budgets are separated: crash retries are per-call (5 max in `sendMonitoredPrompt`), criteria retries are per failure action loop. A crash exhausting its per-call budget during a criteria retry loop is treated as a failed iteration rather than killing the task.
+- Any error generated in the copilot session.
+- A final decision about the task succeeded or failed.
+
+Information passing to `taskDecision` should include the following prefix in order to better categories:
+- `[SESSION STARTED] (driving|task) session started with model {modelId}`, always say `task session` for single managed session mode.
+- `[SESSION CRASHED]` with detailed information from the exception.
+- `[TASK SUCCEEDED]`
+- `[TASK FAILED]`
+- `[AVAILABILITY]`
+- `[CRITERIA]`
+- `[OPERATION]`
+- `[DECISION]`
+- `[MISC]`
+
+For `availability` and `criteria`, the information should at least contain:
+- The task does not match which field and what is the content of the field (field in `availability` and `criteria` properties)
+- Why it does not match
+
+## Running Jobs
+
+**Referenced by**:
+- API_Task.md: `### sendMonitoredPrompt (crash retry)`
+
+A `Job` is workflow of multiple `Task`. If its work fails, the job fails.
+
+### Work
+
+**Referenced by**:
+- JobsData.md: `### TaskWork`, `### Determine TaskWork.workId`
+- API_Job.md: `### copilot/job/start/{job-name}`
+
+- `TaskWork`: run the task, if `modelOverride` is defined that model is used.
+  - If `category` is defined, the model id is `entry.models[category]`.
+  - Otherwise `id` is the model id.
+- `SequentialWork`, run each work sequentially, any failing work immediately fails the `SequentialWork` without continuation.
+  - Empty `works` makes `SequentialWork` succeeds.
+- `ParallelWork`, run all works parallelly, any failing work fails the `ParallelWork`, but it needs to wait until all works finishes.
+  - Empty `works` makes `ParallelWork` succeeds.
+- `LoopWork`:
+  - Before running `body`, if `preCondition` succeeds (first element is true) or fails (first element is false), run `body`, otherwise `LoopWork` finishes as succeeded.
+  - After running `body`, if `postCondition` succeeds (first element is true) or fails (first element is false), redo `LoopWork`, otherwise `LoopWork` finishes as succeeded.
+  - If `body` fails, `LoopWork` finishes and fail.
+- `AltWork`:
+  - If `condition` succeeds, choose `trueWork`, otherwise choose `falseWork`.
+  - If the chosen work is undefined, `AltWork` finishes as succeeded.
+  - If the chosen work succeeds, `AltWork` finishes as succeeded.
+
+**TEST-NOTE-BEGIN**
+Need individual test cases for each type of `Work` in `work.test.mjs`, verifying details of each statement in the above bullet-point.
+Such test case could be implemented by making up a job and calls `api/copilot/job/start/{job-name}` to start a work.
+You can firstly obtain the updated work by calling `api/copilot/job`, find your target job, `workIdForJob` should have been attached to each `TaskWork`.
+By calling the `api/copilot/job/{job-id}/live/{token}` api, you are able to see the starting and ending order of each `TaskWork`, by their own `workIdForJob`.
+
+With such information, you can verify:
+- If the job succeeded or failed as expected.
+- If each `TaskWork` actually triggered in the expected order or logic.
+  - For `AltWork`, only one of `trueWork` or `falseWork` triggers.
+  - For `ParallelWork`, all `works` should trigger but the order may vary.
+- Any task could fail, assert its side effect on the control flow.
+  - For example, if `AltWork.condition` succeeds but a defined `trueWork` does not happen, there should be problems.
+  - You need to check all possible equivalence classes of execution paths according to the control flow.
+
+More details for api and additional test notes could be found in `API.md`.
+**TEST-NOTE-END**
+
+### TaskWork
+
+**Referenced by**:
+- JobsData.md: `### Determine TaskWork.workId`
+
+When a task is executed by a `TaskWork`, it is in `managed session mode`.
+The job has to start all sessions.
+`TaskWork` fails if the last retry:
+- Does not pass `Task.availability` checking. Undefined means the check always succeeds.
+- Does not pass `Task.criteria` checking. Undefined means the check always succeeds.
+
+### Determine TaskWork.workId
+
+**Referenced by**:
+- API_Job.md: `### copilot/job`
+
+Any `TaskWork` must have an unique `workIdInJob` in a Job.
+The `assignWorkId` function converts a `Work` to `Work` with property `workIdInJob` assigned.
+When creating a `Work` AST, you can create one in `Work` without worrying about `workIdInJob`, and call `assignWorkId` to fix that for you.
+
+### Exception Handling
+
+**Referenced by**:
+- API_Task.md: `### sendMonitoredPrompt (crash retry)`
+
+If any task crashes:
+- The job stops immediately and marked failed.
+- The exception cannot be consumed silently.
diff --git a/.github/Agent/prompts/spec/CopilotPortal/Shared.md b/.github/Agent/prompts/spec/CopilotPortal/Shared.md
new file mode 100644
index 00000000..03914f8a
--- /dev/null
+++ b/.github/Agent/prompts/spec/CopilotPortal/Shared.md
@@ -0,0 +1,122 @@
+# Specification
+
+Root folder of the project is `REPO-ROOT/.github/Agent`.
+Read `README.md` to understand the whole picture of the project as well as specification organizations.
+
+## Related Files
+
+- `assets`
+  - `messageBlock.css`
+  - `messageBlock.js`
+  - `sessionResponse.css`
+  - `sessionResponse.js`
+
+### messageBlock.css
+
+Put messageBlock.js specific css file in messageBlock.css.
+
+### messageBlock.js
+
+**Referenced by**:
+- Shared.md: `### sessionResponse.js`
+
+It exposes some APIs in this schema
+
+```typescript
+export class MessageBlock {
+  constructor(blockType: "User" | "Reasoning" | "Tool" | "Message");
+  appendData(data: string): void;
+  replaceData(data: string): void;
+  complete(): void;
+  get isCompleted(): boolean;
+  get divElement(): HTMLDivElement;
+  get title(): string;
+  set title(value: string);
+}
+
+export function getMessageBlock(div: HTMLDivElement): MessageBlock | undefined;
+```
+
+Each `MessageBlock` has a title, displaying: "blockType (title) [receiving...]" or "blockType (title)".
+When `title` is empty, "()" and the space before it must be omitted.
+Receiving appears when it is not completed yet.
+
+When a `MessageBlock` is created and receiving data, the height is limited to 150px, clicking the header does nothing
+
+When a `MessageBlock` is completed:
+- If this `MessageBlock` is "User" and "Message", it will expand, otherwise collapse.
+- Completing a `MessageBlock` should not automatically expand or collapse other blocks.
+- Clicking the header of a completed `MessageBlock` switch between expanding or collapsing.
+- There is no more height limit, it should expands to render all data.
+- A button appears at the very right of the header, it should fills full height.
+  - When the content is rendering as markdown, it shows "View Raw Data", clicking it shows the raw data.
+  - When the content is raw data, it shows "View Markdown", clicking it shows the markdown rendering of the raw data.
+
+Before a `MessageBlock` is completed, raw data should render.
+After it is completed, assuming the data is markdown document and render it properly:
+- Except for "Tool" block, and "Tool" block should not render the button switching between raw data and markdown.
+- Try to tell if the raw content is markdown or just ordinary text, if it doesn't look like a markdown, do not do the markdown rendering automatically.
+
+Inside the `MessageBlock`, it holds a `
` to change the rendering. +And it should also put itself in the element (e.g. in a field with a unique name) so that the object will not be garbage-collected. + +### sessionResponse.css + +Put sessionResponse.js specific css file in sessionResponse.css. + +### sessionResponse.js + +**Referenced by**: +- Index.md: `#### Session Part`, `#### Session Interaction`, `#### Request Part` + +It exposes a `SessionResponseRenderer` class in this schema: + +```typescript +export class SessionResponseRenderer { + constructor(div: HTMLDivElement); + processCallback(data: object): string; + addUserMessage(text: string, title?: string): void; + setAwaiting(awaiting: boolean): void; + scrollToBottom(): void; +} +``` + +`SessionResponseRenderer` is a pure rendering component that does not touch any Copilot related API. +An empty div element is passed to the constructor; all child elements are dynamically created inside it. +The container div gets a CSS class `session-response-container` for styling (scrollable, padded). + +It manages an internal map of `MessageBlock` instances keyed by `"blockType-blockId"`. +An "Awaits responses ..." status element is created and appended to the container. + +- `processCallback(data)`: Processes a callback object (from the live polling API) and handles creating/updating/completing message blocks for Reasoning, Message, Tool, and onGeneratedUserPrompt callbacks. For `onGeneratedUserPrompt`, it calls `addUserMessage(data.prompt, "Task")`. Returns the callback name string so the caller can react to lifecycle events (e.g. `"onAgentEnd"`). +- `addUserMessage(text)`: Creates a "User" `MessageBlock`, appends the text, completes it, and scrolls to bottom. +- `setAwaiting(awaiting)`: Shows or hides the "Awaits responses ..." status text. +- `scrollToBottom()`: Scrolls the container to the bottom. + +#### Session Response Rendering + +Session responses generates 3 types of message block: +- Reasoning +- Tool +- Message +Multiple of them could happen parallelly. + +When `ICopilotSessionCallbacks::onStartXXX` happens, a new message block should be created. +When `ICopilotSessionCallbacks::onXXX` happens, the data should be appended (`appendData`) to the message block. +When `ICopilotSessionCallbacks::onEndReasoning/onEndMessage` happens, replace (`replaceData`) the message block with the complete contet. + +The content of a "Tool" `MessageBlock` needs to be taken care of specially: +- The first line should be in its title. It is easy to tell when the `title` property is empty. +- `ICopilotSessionCallbacks::onEndToolExecution` will give you optional extra information. +Responses for different message blocks are identified by its id. + +Message blocks stack vertically from top to bottom. +`MessageBlock` in messageBlock.js should be used to control any message block. + +You are recommended to maintain a list of message blocks in a map with key "blockType-blockId" in its rendering order. + +When `setAwaiting(true)` is called, +there must be a text at the left bottom corner of the session part saying "Awaits responses ...". +When `setAwaiting(false)` is called, this text disappears. + +The session response container is scrollable. diff --git a/.github/Agent/prompts/spec/CopilotPortal/Test.md b/.github/Agent/prompts/spec/CopilotPortal/Test.md new file mode 100644 index 00000000..5fda978d --- /dev/null +++ b/.github/Agent/prompts/spec/CopilotPortal/Test.md @@ -0,0 +1,13 @@ +# Specification + +Root folder of the project is `REPO-ROOT/.github/Agent`. +Read `README.md` to understand the whole picture of the project as well as specification organizations. + +## Related Files + +- `assets` + - `test.html` + +### test.html + +This is a test page, when it is loaded, it queries `api/test` and print the message field to the body. diff --git a/.github/Agent/prompts/verifyJobs.prompt.md b/.github/Agent/prompts/verifyJobs.prompt.md new file mode 100644 index 00000000..5a500865 --- /dev/null +++ b/.github/Agent/prompts/verifyJobs.prompt.md @@ -0,0 +1,32 @@ +# Verify jobsData.ts against prompts + +`REPO-ROOT/.github/Agent/packages/CopilotPortal/src/jobsData.ts` contains definition of all tasks and jobs. +Read `REPO-ROOT/.github/Agent/prompts/spec/CopilotPortal/JobsData.md` to understand the specification of jobsData. +You goal is to check all prompts in `jobsData.ts` against all prompt files it mentioned and identify: +- All references to unexisting files. +- All unclear references. +- All typos. +- All logic errors. Most of prompts are telling the agent to follow instructions of other prompt files. You need to read those files and make sure instructions in `jobsData.ts` are consistent with those files. + +Fix what you have found in `jobsData.ts` that matches the above list. + +Be aware of file paths in `jobsData.ts` prompts: +- When any `REPO-ROOT/.github` folder is mentioned, it is `REPO-ROOT/Copilot` in this repo. +- When any `REPO-ROOT/.github/TaskLogs/*.md` file is mentioned, they do not exist in this repo but they will be generated by prompt files in `REPO-ROOT/Copilot/prompts`. +- When `AGENTS.md` or `CLAUDE.md` is mentioned, they are `REPO-ROOT/AGENTS.md` in this repo. + +## Undestanding Prompt Files + +Prompt files mentioned are for developing heavy C++ projects. They are typically be used in the following order: +- `scrum`: Create a scrum board for a big feature, breaking it down into a set of small tasks. +- `design`: Create a design document for a task in the scrum board, or for a task specified directly. +- `plan`: Make detailed plan from the design document. +- `summary`: Summarize all code changes from the plan document. +- `execute` and `verify`: Coding works according to summarized plan. +- `scrum learn` and `refine`: Learn from all created documents for one task. They may contain review comments input from human. + +Documents created from the first four steps can be reviewed by a AI team. The process is: +- Call all predefined models to run `review scrum|design|plan|summary`. Each model review the specified document and other models' reviews. +- Once they agree to each other, run `review final` and `review apply`, to update the document under review. +- Human will optionally review the final document and their review comments will be added. + - The first four steps has their own human review process. diff --git a/.github/Agent/prompts/verifySpec.prompt.md b/.github/Agent/prompts/verifySpec.prompt.md new file mode 100644 index 00000000..5f955e33 --- /dev/null +++ b/.github/Agent/prompts/verifySpec.prompt.md @@ -0,0 +1,40 @@ +# Verify Specification Files + +The root of this project `REPO-ROOT/.github/Agent` is not the workspace root. +- All folders and files mentioned in the instructions are in the `REPO-ROOT/.github/Agent` folder. +- You are recommended to read `README.md` to understand the whole picture of the project as well as specification organizations. +- You are recommended to read all spec files to understand dependencies and relationships between each component. + +You goal is to ensure all specifications are not outdated and have no mistakes. +- Verify spec files in `prompts/spec` folder. +- Ignore spec files in `prompts/snapshot` folder, they are a copy of `prompts/spec`. + +This task is usually executed when: +- A big progress has been made in the project. +- Refactoring including file restructuring of spec files. + +## Sync Spec with Source Files + +Important interfaces, including types and functions, are mentioned in spec files. +You need to verify all of them against the source files. +If the spec does not match the source files, fix them. +Spec mentions types and functions everywhere, ensure all of them exist and correct. + +## Verify References + +There are bullet lists under `**Referenced by**:` for most of the sessions, their format is: +``` +- SpecFile.md: `# This Section`, `### That Section`... +``` +Each file occupys one line. +It means the behavior of those sections depend on the current section, when the current section is changed, those sections probably need to check. + +Ensure file names and session names exists, and fix the list if you find any missing or oudated dependencies. + +## Grammar and Typos + +Fix typos and grammar or syntax mistakes in spec files. + +## README.md + +Scan `README.md` and ensure all information is up to date, especially file lists. diff --git a/.github/Agent/tsconfig.json b/.github/Agent/tsconfig.json new file mode 100644 index 00000000..13afce66 --- /dev/null +++ b/.github/Agent/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["packages/*/src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/.github/Agent/yarn.lock b/.github/Agent/yarn.lock new file mode 100644 index 00000000..41dc5a3b --- /dev/null +++ b/.github/Agent/yarn.lock @@ -0,0 +1,107 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@github/copilot-darwin-arm64@0.0.403": + version "0.0.403" + resolved "https://registry.yarnpkg.com/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-0.0.403.tgz#e1cbc91f73639c8217fd2cc61498bc311e2af45c" + integrity sha512-dOw8IleA0d1soHnbr/6wc6vZiYWNTKMgfTe/NET1nCfMzyKDt/0F0I7PT5y+DLujJknTla/ZeEmmBUmliTW4Cg== + +"@github/copilot-darwin-x64@0.0.403": + version "0.0.403" + resolved "https://registry.yarnpkg.com/@github/copilot-darwin-x64/-/copilot-darwin-x64-0.0.403.tgz#b246b0b91f31e13650d99c59716fd74ab87465f5" + integrity sha512-aK2jSNWgY8eiZ+TmrvGhssMCPDTKArc0ip6Ul5OaslpytKks8hyXoRbxGD0N9sKioSUSbvKUf+1AqavbDpJO+w== + +"@github/copilot-linux-arm64@0.0.403": + version "0.0.403" + resolved "https://registry.yarnpkg.com/@github/copilot-linux-arm64/-/copilot-linux-arm64-0.0.403.tgz#5453ec3bd565cc92676b450b2edb66e49f60909d" + integrity sha512-KhoR2iR70O6vCkzf0h8/K+p82qAgOvMTgAPm9bVEHvbdGFR7Py9qL5v03bMbPxsA45oNaZAkzDhfTAqWhIAZsQ== + +"@github/copilot-linux-x64@0.0.403": + version "0.0.403" + resolved "https://registry.yarnpkg.com/@github/copilot-linux-x64/-/copilot-linux-x64-0.0.403.tgz#17a7eba380be8553610ee6632d6a81ba229722eb" + integrity sha512-eoswUc9vo4TB+/9PgFJLVtzI4dPjkpJXdCsAioVuoqPdNxHxlIHFe9HaVcqMRZxUNY1YHEBZozy+IpUEGjgdfQ== + +"@github/copilot-sdk@^0.1.4": + version "0.1.23" + resolved "https://registry.yarnpkg.com/@github/copilot-sdk/-/copilot-sdk-0.1.23.tgz#120986bf5719880dedf076c0f2a55f855566ff40" + integrity sha512-0by81bsBQlDKE5VbcegZfUMvPyPm1aXwSGS2rGaMAFxv3ps+dACf1Voruxik7hQTae0ziVFJjuVrlxZoRaXBLw== + dependencies: + "@github/copilot" "^0.0.403" + vscode-jsonrpc "^8.2.1" + zod "^4.3.6" + +"@github/copilot-win32-arm64@0.0.403": + version "0.0.403" + resolved "https://registry.yarnpkg.com/@github/copilot-win32-arm64/-/copilot-win32-arm64-0.0.403.tgz#3cdaee25b2454ceb6d8293f06b258c95307b94ae" + integrity sha512-djWjzCsp2xPNafMyOZ/ivU328/WvWhdroGie/DugiJBTgQL2SP0quWW1fhTlDwE81a3g9CxfJonaRgOpFTJTcg== + +"@github/copilot-win32-x64@0.0.403": + version "0.0.403" + resolved "https://registry.yarnpkg.com/@github/copilot-win32-x64/-/copilot-win32-x64-0.0.403.tgz#d22bdd50d9b0674d73981a413df881fd8229f5ce" + integrity sha512-lju8cHy2E6Ux7R7tWyLZeksYC2MVZu9i9ocjiBX/qfG2/pNJs7S5OlkwKJ0BSXSbZEHQYq7iHfEWp201bVfk9A== + +"@github/copilot@^0.0.403": + version "0.0.403" + resolved "https://registry.yarnpkg.com/@github/copilot/-/copilot-0.0.403.tgz#56e44b5a0640685f0b34507bbb12f47229798940" + integrity sha512-v5jUdtGJReLmE1rmff/LZf+50nzmYQYAaSRNtVNr9g0j0GkCd/noQExe31i1+PudvWU0ZJjltR0B8pUfDRdA9Q== + optionalDependencies: + "@github/copilot-darwin-arm64" "0.0.403" + "@github/copilot-darwin-x64" "0.0.403" + "@github/copilot-linux-arm64" "0.0.403" + "@github/copilot-linux-x64" "0.0.403" + "@github/copilot-win32-arm64" "0.0.403" + "@github/copilot-win32-x64" "0.0.403" + +"@playwright/test@^1.49.0": + version "1.58.2" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.58.2.tgz#b0ad585d2e950d690ef52424967a42f40c6d2cbd" + integrity sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA== + dependencies: + playwright "1.58.2" + +"@types/node@^22.10.5": + version "22.19.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.11.tgz#7e1feaad24e4e36c52fa5558d5864bb4b272603e" + integrity sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w== + dependencies: + undici-types "~6.21.0" + +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +playwright-core@1.58.2: + version "1.58.2" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.58.2.tgz#ac5f5b4b10d29bcf934415f0b8d133b34b0dcb13" + integrity sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg== + +playwright@1.58.2: + version "1.58.2" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.58.2.tgz#afe547164539b0bcfcb79957394a7a3fa8683cfd" + integrity sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A== + dependencies: + playwright-core "1.58.2" + optionalDependencies: + fsevents "2.3.2" + +typescript@^5.7.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== + +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== + +vscode-jsonrpc@^8.2.1: + version "8.2.1" + resolved "https://registry.yarnpkg.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz#a322cc0f1d97f794ffd9c4cd2a898a0bde097f34" + integrity sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ== + +zod@^4.3.6: + version "4.3.6" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" + integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== diff --git a/.github/Scripts/copilotGitCommit.ps1 b/.github/Scripts/copilotGitCommit.ps1 new file mode 100644 index 00000000..54d08125 --- /dev/null +++ b/.github/Scripts/copilotGitCommit.ps1 @@ -0,0 +1,5 @@ +Push-Location $PSScriptRoot\..\.. +git add . +git status +git commit -am "[BOT] Backup." +Pop-Location diff --git a/.github/bot.ps1 b/.github/bot.ps1 new file mode 100644 index 00000000..ea1844ca --- /dev/null +++ b/.github/bot.ps1 @@ -0,0 +1,9 @@ +Push-Location $PSScriptRoot\Agent +try { + yarn install + yarn compile + start powershell {yarn portal --port 9999} +} +finally { + Pop-Location +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index f954590c..a15432b1 100644 --- a/.gitignore +++ b/.gitignore @@ -176,6 +176,7 @@ ClientBin/ *.publishsettings node_modules/ bower_components/ +!.github/Agent/**/* # RIA/Silverlight projects Generated_Code/