Update prompt

This commit is contained in:
vczh
2026-03-01 19:07:14 -08:00
parent 2abc44346b
commit ead17ea468
67 changed files with 14726 additions and 0 deletions

6
.github/Agent/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.DS_Store
.env
*.tsbuildinfo

145
.github/Agent/CopilotTools.md vendored Normal file
View File

@@ -0,0 +1,145 @@
# Copilot CLI Predefined Tools
This file lists the tools available in this Copilot CLI environment, with each tools 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.

179
.github/Agent/README.md vendored Normal file
View File

@@ -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 <number>` — 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

22
.github/Agent/package.json vendored Normal file
View File

@@ -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"
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Copilot Portal</title>
<link rel="stylesheet" href="index.css">
<link rel="stylesheet" href="messageBlock.css">
<link rel="stylesheet" href="sessionResponse.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body>
<div id="setup-ui">
<label>
Model
<select id="model-select"></select>
</label>
<label>
Working Directory
<input type="text" id="working-dir" placeholder="Absolute path...">
</label>
<div id="setup-buttons">
<div id="setup-buttons-left">
<button id="jobs-button">New Job</button>
<button id="refresh-button">Refresh</button>
</div>
<button id="start-button" disabled>Start</button>
</div>
<div id="running-jobs-list"></div>
</div>
<div id="session-ui">
<div id="session-part"></div>
<div id="resize-bar"></div>
<div id="request-part">
<div id="task-row">
<label for="task-select">Choose a Task:&nbsp;</label>
<select id="task-select"><option value="">(none)</option></select>
</div>
<textarea id="request-textarea" placeholder="Type your request..."></textarea>
<div id="request-buttons">
<div id="request-buttons-left">
<button id="stop-server-button">Stop Server</button>
<button id="close-session-button">Close Session</button>
</div>
<button id="send-button" disabled>Send</button>
</div>
</div>
</div>
<script type="module" src="index.js"></script>
</body>
</html>

View File

@@ -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 = '<div style="display:flex;align-items:center;justify-content:center;height:100vh;font-family:sans-serif;color:#ccc;background:#1e1e1e;"><h1>Session ended — you may close this tab.</h1></div>';
}, 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();

View File

@@ -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;
}

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Copilot Portal - Job Tracking</title>
<link rel="stylesheet" href="jobTracking.css">
<link rel="stylesheet" href="messageBlock.css">
<link rel="stylesheet" href="sessionResponse.css">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>mermaid.initialize({ startOnLoad: false });</script>
</head>
<body>
<div id="left-part">
<div id="job-part"></div>
</div>
<div id="resize-bar"></div>
<div id="right-part">
<div id="session-response-part"></div>
</div>
<script src="flowChartMermaid.js"></script>
<script type="module" src="jobTracking.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Copilot Portal - Jobs</title>
<link rel="stylesheet" href="jobs.css">
</head>
<body>
<div id="left-part">
<div id="matrix-part"></div>
</div>
<div id="resize-bar"></div>
<div id="right-part">
<div id="user-input-part">
<textarea id="user-input-textarea" placeholder="Type user input..." disabled></textarea>
<div id="user-input-buttons">
<button id="start-job-button" disabled>Job Not Selected</button>
<button id="preview-button" disabled>Preview</button>
</div>
</div>
</div>
<script type="module" src="jobs.js"></script>
</body>
</html>

View File

@@ -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 = '<h1>Server stopped — you may close this tab.</h1>';
}, 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();

View File

@@ -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;
}

View File

@@ -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];
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Test</title>
</head>
<body>
<script>
fetch("/api/test")
.then(r => r.json())
.then(data => {
document.body.textContent = data.message;
});
</script>
</body>
</html>

View File

@@ -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"
}
}

View File

@@ -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<string, SessionState>();
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<ModelInfo[]> {
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<void> {
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<void> {
jsonResponse(res, 200, { repoRoot });
}
export async function apiToken(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
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<void> {
jsonResponse(res, 200, {});
shutdownServer(server);
}
export async function apiCopilotModels(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const state = sessions.get(sessionId);
const response = await waitForLiveResponse(
state?.entity,
token,
5000,
"SessionNotFound",
"SessionClosed",
);
jsonResponse(res, 200, response);
}

View File

@@ -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<void>;
stop(): Promise<void>;
}
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<ICopilotSession> {
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<string, string>();
const messageContentById = new Map<string, string>();
const toolOutputById = new Map<string, string>();
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<void> {
await session.sendAndWait({ prompt: message }, timeout);
},
async stop(): Promise<void> {
await session.destroy();
},
};
}

View File

@@ -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<string, string> = {
".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<void> {
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<void> {
// 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`);
});

View File

@@ -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<number, JobTaskStatus>;
}
const jobs = new Map<string, JobState>();
let nextJobId = 1;
// ---- executeWork ----
async function executeWork(
entry: Entry,
work: Work<number>,
userInput: string,
workingDirectory: string,
runningIds: Set<number>,
stopped: { readonly value: boolean },
activeTasks: ICopilotTask[],
callback: ICopilotJobCallback
): Promise<boolean> {
if (stopped.value) return false;
switch (work.kind) {
case "Ref": {
const taskWork = work as TaskWork<number>;
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<boolean>((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<number>;
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<number>;
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<number>;
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<number>;
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<ICopilotJob> {
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<number>();
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<void> {
// Build a combined chart from all jobs
const chart: Record<string, ReturnType<typeof generateChartNodes>> = {};
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<void> {
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<void> | 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<void> {
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<void> {
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<void> {
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<void> {
const state = jobs.get(jobId);
const response = await waitForLiveResponse(
state?.entity,
token,
5000,
"JobNotFound",
"JobsClosed",
);
jsonResponse(res, 200, response);
}

View File

@@ -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<number>["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<number>, 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<number>, 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<number>): ChartGraph {
const nodes: ChartNode[] = [];
buildChart(work, [0], nodes);
return { nodes };
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<T> {
kind: "Ref";
workIdInJob: T;
taskId: string;
modelOverride?: Model;
}
export interface SequentialWork<T> {
kind: "Seq";
works: Work<T>[];
}
export interface ParallelWork<T> {
kind: "Par";
works: Work<T>[];
}
export interface LoopWork<T> {
kind: "Loop";
preCondition?: [boolean, Work<T>];
postCondition?: [boolean, Work<T>];
body: Work<T>;
}
export interface AltWork<T> {
kind: "Alt";
condition: Work<T>;
trueWork?: Work<T>;
falseWork?: Work<T>;
}
export type Work<T> = TaskWork<T> | SequentialWork<T> | ParallelWork<T> | LoopWork<T> | AltWork<T>;
export function assignWorkId(work: Work<never>): Work<number> {
function helper(w: Work<never>, nextId: number[]): Work<number> {
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<number>;
}
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<string, string>): 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 "<MISSING>";
});
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<never>);
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<unknown>, codePath: string, modelKeys: string[]): void {
switch (work.kind) {
case "Ref": {
const tw = work as TaskWork<unknown>;
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<unknown>;
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<unknown>;
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<unknown>;
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<unknown>;
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<unknown>): string[] {
const ids: string[] = [];
function collect(w: Work<unknown>): void {
switch (w.kind) {
case "Ref":
ids.push((w as TaskWork<unknown>).taskId);
break;
case "Seq":
case "Par":
for (const child of (w as SequentialWork<unknown> | ParallelWork<unknown>).works) {
collect(child);
}
break;
case "Loop": {
const lw = w as LoopWork<unknown>;
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<unknown>;
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<T>(work: Work<T>): Work<T> {
switch (work.kind) {
case "Ref":
return work;
case "Seq": {
const flatWorks: Work<T>[] = [];
for (const child of (work as SequentialWork<T>).works) {
const simplified = simplifyWork(child);
if (simplified.kind === "Seq") {
flatWorks.push(...(simplified as SequentialWork<T>).works);
} else {
flatWorks.push(simplified);
}
}
return { ...work, works: flatWorks } as SequentialWork<T>;
}
case "Par": {
const flatWorks: Work<T>[] = [];
for (const child of (work as ParallelWork<T>).works) {
const simplified = simplifyWork(child);
if (simplified.kind === "Par") {
flatWorks.push(...(simplified as ParallelWork<T>).works);
} else {
flatWorks.push(simplified);
}
}
return { ...work, works: flatWorks } as ParallelWork<T>;
}
case "Loop": {
const lw = work as LoopWork<T>;
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<T>;
}
case "Alt": {
const aw = work as AltWork<T>;
return {
...aw,
condition: simplifyWork(aw.condition),
trueWork: aw.trueWork ? simplifyWork(aw.trueWork) : undefined,
falseWork: aw.falseWork ? simplifyWork(aw.falseWork) : undefined,
} as AltWork<T>;
}
}
}

View File

@@ -0,0 +1,313 @@
import * as http from "node:http";
import { CopilotClient } from "@github/copilot-sdk";
// ---- Helpers ----
export function readBody(req: http.IncomingMessage): Promise<string> {
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<typeof setTimeout> | null;
batchTimeout: ReturnType<typeof setTimeout> | null;
}
export interface LiveEntityState {
responses: LiveResponse[];
tokens: Map<string, TokenState>;
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<LiveResponse> {
// 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<CopilotClient> | null = null;
export async function ensureCopilotClient(): Promise<CopilotClient> {
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;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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
});
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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" }]
}
}
}
}

View File

@@ -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("; ")}`);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -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!");
});
});

View File

@@ -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);
};
}

View File

@@ -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");
});
});

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -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<void>;`
- 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<ModelInfo[]>`
- 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.

View File

@@ -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<ICopilotJob>
```
## 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`.

View File

@@ -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<void>;`
- 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<void>;
stop(): Promise<void>
}
```
- 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<ICopilotSession>;`
- 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
}
```

View File

@@ -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<ICopilotTask>
```
- 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.

View File

@@ -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.

View File

@@ -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 `<pre>` 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.

View File

@@ -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 `<MISSING>` 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<never>` to `Work<number>` with property `workIdInJob` assigned.
When creating a `Work` AST, you can create one in `Work<never>` 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.

View File

@@ -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 `<div/>` 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.

View File

@@ -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.

Some files were not shown because too many files have changed in this diff Show More