Show only cloud coding agent tasks in cloud sessions list (v2) (#323195)

* Show only cloud coding agent tasks in cloud sessions list (v2)

Two fixes to the Task API (v2) cloud sessions list, where local VS Code /
CLI / JetBrains sessions mirrored into Mission Control were leaking in and
settled tasks were stuck showing "In Progress".

- Filter the list to cloud coding agent tasks only. The owning surface is
  identified by the agent integration slug on a task's `agent_collaborators`
  (`copilot-developer` / `copilot-swe-agent` = cloud; `copilot-developer-cli`,
  `vscode-chat`, `jetbrains-chat` = local clients). Adds the exported pure
  helper `isCloudCodingAgentTask` and applies it in `fetchSessionList`.
  `agent_collaborators` is returned to first-party CAPI tokens but not yet
  modeled in `@vscode/copilot-api`, so a minimal local type is used.

- Carry the raw `AgentTaskState` across the backend seam and map it directly
  to `ChatSessionStatus` in the provider, so `idle` renders as Completed and
  `waiting_for_user` as NeedsInput instead of collapsing to InProgress.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review: guard malformed slugs and add status fallback

- isCloudCodingAgentTask: use a `typeof c.slug === 'string'` guard so null /
  non-string slugs in the untyped server payload can't reach `Set.has`.
- taskStateToChatSessionStatus: add a `default` branch returning InProgress so
  an unknown/forward-compat task state can't yield an invalid `undefined`
  status. Added tests for both.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Osvaldo Ortega
2026-06-26 12:40:05 -07:00
committed by GitHub
parent f0c0de9253
commit cbb3fec2b4
4 changed files with 170 additions and 8 deletions

View File

@@ -5,7 +5,7 @@
import * as pathLib from 'path';
import * as vscode from 'vscode';
import type { AgentTaskSessionEvent } from '@vscode/copilot-api';
import type { AgentTaskSessionEvent, AgentTaskState } from '@vscode/copilot-api';
import { l10n, Uri } from 'vscode';
import { IAuthenticationService } from '../../../platform/authentication/common/authentication';
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
@@ -150,6 +150,36 @@ export function parseSessionLogChunksSafely(rawText: string, logService: ILogSer
}
}
/**
* Map a Task API lifecycle state (v2) directly to a {@link vscode.ChatSessionStatus}. Unlike v1's
* PR-derived status (which routes through the lossy `SessionInfo['state']`), this keeps the
* non-terminal "agent handed the turn back" states distinct from active work: `idle` (agent
* finished its turn, nothing pending) renders as Completed and `waiting_for_user` (agent paused for
* input) renders as NeedsInput. Only `queued`/`in_progress` are genuinely InProgress — collapsing
* `idle`/`waiting_for_user` into InProgress made settled tasks look like they were still running.
*/
export function taskStateToChatSessionStatus(state: AgentTaskState): vscode.ChatSessionStatus {
switch (state) {
case 'queued':
case 'in_progress':
return vscode.ChatSessionStatus.InProgress;
case 'waiting_for_user':
return vscode.ChatSessionStatus.NeedsInput;
case 'idle':
case 'completed':
return vscode.ChatSessionStatus.Completed;
case 'failed':
case 'timed_out':
case 'cancelled':
return vscode.ChatSessionStatus.Failed;
default:
// Forward-compat fallback: a state outside the known union (e.g. a state added
// server-side after this build) must not yield an invalid `undefined` status. Treat
// unknowns as InProgress — the conservative "agent may still own the turn" assumption.
return vscode.ChatSessionStatus.InProgress;
}
}
const CUSTOM_AGENTS_OPTION_GROUP_ID = 'customAgents';
const MODELS_OPTION_GROUP_ID = 'models';
const PARTNER_AGENTS_OPTION_GROUP_ID = 'partnerAgents';
@@ -1272,6 +1302,13 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
const sessionItem = entry.latestSession;
const createdAt = validateISOTimestamp(sessionItem.created_at);
// Task-backed entries (v2) carry their raw lifecycle state; map it directly so the
// agent's post-turn states (`idle`/`waiting_for_user`) don't render as InProgress.
// Jobs API (v1) entries fall back to the PR-derived session state.
const status = entry.taskState !== undefined
? taskStateToChatSessionStatus(entry.taskState)
: this.getSessionStatusFromSession(sessionItem);
// Task-shaped card path (v2 with no resolvable PR yet, or PR-less by design).
if (!pr) {
if (!entry.pullArtifact && entry.latestSession.resource_type !== 'task') {
@@ -1288,7 +1325,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
return {
resource: vscode.Uri.from({ scheme: CopilotCloudSessionsProvider.TYPE, path: '/' + SessionIdForTask.getId(taskId) }),
label: entry.latestSession.name || taskId,
status: this.getSessionStatusFromSession(sessionItem),
status,
...(changes?.length ? { changes } : {}),
...(createdAt ? {
timing: {
@@ -1327,7 +1364,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
const session = {
resource,
label: pr.title,
status: this.getSessionStatusFromSession(sessionItem),
status,
badge: this.getPullRequestBadge(repoIds, pr),
tooltip: this.createPullRequestTooltip(pr),
...(createdAt ? {

View File

@@ -64,6 +64,36 @@ function mapTaskStateToSessionState(state: AgentTaskState): SessionInfo['state']
}
}
/**
* Agent integration slugs that identify the Copilot cloud coding agent. CMC/CAPI returns
* `copilot-developer`; the monolith uses `copilot-swe-agent` for the same agent. Tasks owned by
* any other surface — `copilot-developer-cli` (Copilot CLI), `vscode-chat` (VS Code) or
* `jetbrains-chat` (JetBrains) — are local clients mirrored into Mission Control and must not
* appear in the cloud sessions list. See github-ui `agent-helpers.ts` (`isCopilotCodingAgent`) and
* `agent-profile.ts`.
*/
const CLOUD_CODING_AGENT_SLUGS: ReadonlySet<string> = new Set(['copilot-developer', 'copilot-swe-agent']);
/**
* The owning agent integration of a task. Mirrors CMC's internal `TaskCollaborator`
* (`agent_collaborators`), which first-party CAPI tokens receive but `@vscode/copilot-api`'s
* `AgentTask` does not yet model. Only `slug` is needed to identify the client surface.
*/
interface TaskAgentCollaborator {
readonly slug?: string;
}
/**
* Whether a task is owned by the Copilot cloud coding agent rather than a local client surface
* (Copilot CLI / VS Code / JetBrains). The owning surface is identified by the agent integration
* slug on the task's `agent_collaborators`; tasks without a recognized cloud slug are treated as
* non-cloud and excluded from the cloud sessions list.
*/
export function isCloudCodingAgentTask(task: AgentTask): boolean {
const collaborators = (task as AgentTask & { readonly agent_collaborators?: readonly TaskAgentCollaborator[] }).agent_collaborators;
return collaborators?.some(c => typeof c.slug === 'string' && CLOUD_CODING_AGENT_SLUGS.has(c.slug)) ?? false;
}
function findPullArtifact(task: AgentTask): (AgentTaskArtifact & { data: AgentTaskGitHubResourceData }) | undefined {
return task.artifacts?.find(
(a): a is AgentTaskArtifact & { data: AgentTaskGitHubResourceData } =>
@@ -300,11 +330,12 @@ export class TaskApiBackend implements TaskCloudAgentBackend {
}
return tasksWithRepo
.filter(({ task }) => !task.archived_at)
.filter(({ task }) => !task.archived_at && isCloudCodingAgentTask(task))
.map(({ task, repo }): CloudSessionData => ({
latestSession: taskToSessionInfo(task),
pullArtifact: taskToPullArtifactRef(task, repo),
diffRefs: taskToDiffRefs(task, repo),
taskState: task.state,
}));
}

View File

@@ -5,7 +5,7 @@
import { describe, expect, it, vi } from 'vitest';
import * as vscode from 'vscode';
import type { AgentTask, AgentTaskCreateRequest, AgentTaskGetResponse, AgentTaskListEventsResponse, AgentTaskListResponse, AgentTaskSessionEvent, AgentTaskSteerRequest, AgentTaskCreatePullRequestResponse } from '@vscode/copilot-api';
import type { AgentTask, AgentTaskCreateRequest, AgentTaskGetResponse, AgentTaskListEventsResponse, AgentTaskListResponse, AgentTaskSessionEvent, AgentTaskState, AgentTaskSteerRequest, AgentTaskCreatePullRequestResponse } from '@vscode/copilot-api';
import { GithubRepoId, IGitService } from '../../../../platform/git/common/gitService';
import { PullRequestSearchItem, SessionInfo } from '../../../../platform/github/common/githubAPI';
import { TestLogService } from '../../../../platform/testing/common/testLogService';
@@ -13,8 +13,8 @@ import { mock } from '../../../../util/common/test/simpleMock';
import { ChatRequestTurn2, ChatResponseMarkdownPart, ChatResponseTurn2, ChatToolInvocationPart } from '../../../../vscodeTypes';
import { ITaskApiClient, ListTaskEventsOptions, ListTasksOptions } from '../../common/taskApiTypes';
import { ChatSessionContentBuilder } from '../copilotCloudSessionContentBuilder';
import { normalizeInitialSessionOptions, parseSessionLogChunksSafely } from '../copilotCloudSessionsProvider';
import { TaskApiBackend, parseRepoFromTaskUrl } from '../taskApiBackend';
import { normalizeInitialSessionOptions, parseSessionLogChunksSafely, taskStateToChatSessionStatus } from '../copilotCloudSessionsProvider';
import { TaskApiBackend, parseRepoFromTaskUrl, isCloudCodingAgentTask } from '../taskApiBackend';
import { NullCloudBackendInstrumentation } from '../cloudBackendTelemetry';
import { MockOctoKitService } from '../../../agents/vscode-node/test/mockOctoKitService';
@@ -457,6 +457,92 @@ describe('TaskApiBackend', () => {
expect(client.listForRepoCalls).toEqual([]);
expect(client.listCalls).toEqual([{ options: { per_page: 100 } }]);
});
it('fetchSessionList carries the raw task lifecycle state so settled tasks are not shown as in_progress', async () => {
// `idle` collapses to `in_progress` in the legacy SessionInfo shape; the raw `taskState`
// must be preserved alongside it so the provider can render it as Completed/NeedsInput.
const client = new FakeTaskApiClient({ repoTasks: [{ id: 't-idle', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 }, agent_collaborators: [{ slug: 'copilot-developer' }] } as unknown as AgentTask] });
const octoKitService = new MockOctoKitService();
octoKitService.getCurrentAuthedUser = async () => ({ id: 4242, login: 'octocat', name: 'The Octocat', avatar_url: '' });
const backend = new TaskApiBackend(client, new TestLogService(), octoKitService, NullCloudBackendInstrumentation);
const result = await backend.fetchSessionList([new GithubRepoId('octocat', 'hello-world')], false, false);
expect(result.map(r => ({ taskState: r.taskState, sessionState: r.latestSession.state }))).toEqual([
{ taskState: 'idle', sessionState: 'in_progress' },
]);
});
it('fetchSessionList shows only cloud coding agent tasks and excludes local-client tasks (CLI / VS Code / JetBrains)', async () => {
const repoTasks = [
{ id: 'cloud-dev', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 }, agent_collaborators: [{ slug: 'copilot-developer' }] },
{ id: 'cloud-swe', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 }, agent_collaborators: [{ slug: 'copilot-swe-agent' }] },
{ id: 'cli', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 }, agent_collaborators: [{ slug: 'copilot-developer-cli' }] },
{ id: 'vscode', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 }, agent_collaborators: [{ slug: 'vscode-chat' }] },
{ id: 'jetbrains', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 }, agent_collaborators: [{ slug: 'jetbrains-chat' }] },
{ id: 'no-collaborators', state: 'idle', created_at: '2026-03-27T00:00:00Z', creator: { id: 4242 } },
] as unknown as readonly AgentTask[];
const client = new FakeTaskApiClient({ repoTasks });
const octoKitService = new MockOctoKitService();
octoKitService.getCurrentAuthedUser = async () => ({ id: 4242, login: 'octocat', name: 'The Octocat', avatar_url: '' });
const backend = new TaskApiBackend(client, new TestLogService(), octoKitService, NullCloudBackendInstrumentation);
const result = await backend.fetchSessionList([new GithubRepoId('octocat', 'hello-world')], false, false);
expect(result.map(r => r.latestSession.id)).toEqual(['cloud-dev', 'cloud-swe']);
});
});
describe('isCloudCodingAgentTask', () => {
it('keeps cloud coding agent slugs and rejects local-client / missing / malformed slugs', () => {
const classify = (agent_collaborators?: Array<{ slug?: unknown }>) =>
isCloudCodingAgentTask({ id: 't', state: 'idle', created_at: '2026-03-27T00:00:00Z', ...(agent_collaborators && { agent_collaborators }) } as unknown as AgentTask);
expect({
'copilot-developer': classify([{ slug: 'copilot-developer' }]),
'copilot-swe-agent': classify([{ slug: 'copilot-swe-agent' }]),
'copilot-developer-cli': classify([{ slug: 'copilot-developer-cli' }]),
'vscode-chat': classify([{ slug: 'vscode-chat' }]),
'jetbrains-chat': classify([{ slug: 'jetbrains-chat' }]),
'missing-slug': classify([{}]),
'null-slug': classify([{ slug: null }]),
'no-collaborators': classify(undefined),
'empty-collaborators': classify([]),
}).toEqual({
'copilot-developer': true,
'copilot-swe-agent': true,
'copilot-developer-cli': false,
'vscode-chat': false,
'jetbrains-chat': false,
'missing-slug': false,
'null-slug': false,
'no-collaborators': false,
'empty-collaborators': false,
});
});
});
describe('taskStateToChatSessionStatus', () => {
it('maps each Task API lifecycle state to the right ChatSessionStatus', () => {
const states: readonly AgentTaskState[] = ['queued', 'in_progress', 'idle', 'waiting_for_user', 'completed', 'failed', 'timed_out', 'cancelled'];
const mapped = Object.fromEntries(states.map(state => [state, taskStateToChatSessionStatus(state)]));
expect(mapped).toEqual({
queued: vscode.ChatSessionStatus.InProgress,
in_progress: vscode.ChatSessionStatus.InProgress,
// Agent finished its turn / is waiting — must not look like active work.
idle: vscode.ChatSessionStatus.Completed,
waiting_for_user: vscode.ChatSessionStatus.NeedsInput,
completed: vscode.ChatSessionStatus.Completed,
failed: vscode.ChatSessionStatus.Failed,
timed_out: vscode.ChatSessionStatus.Failed,
cancelled: vscode.ChatSessionStatus.Failed,
});
});
it('falls back to InProgress for an unknown/forward-compat state instead of returning undefined', () => {
expect(taskStateToChatSessionStatus('some_new_server_state' as AgentTaskState)).toBe(vscode.ChatSessionStatus.InProgress);
});
});
describe('parseRepoFromTaskUrl', () => {

View File

@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { AgentTaskCreatePullRequestResponse, AgentTaskGetResponse, AgentTaskSessionEvent } from '@vscode/copilot-api';
import { AgentTaskCreatePullRequestResponse, AgentTaskGetResponse, AgentTaskSessionEvent, AgentTaskState } from '@vscode/copilot-api';
import { GithubRepoId } from '../../../platform/git/common/gitService';
import { PullRequestSearchItem, SessionInfo } from '../../../platform/github/common/githubAPI';
@@ -90,6 +90,14 @@ export interface CloudSessionData {
* (changes come from the PR) and for tasks with no branch.
*/
readonly diffRefs?: { readonly owner: string; readonly repo: string; readonly baseRef: string; readonly headRef: string };
/**
* Raw Task API lifecycle state for Task-backed entries (v2). The provider maps this directly
* to a `ChatSessionStatus` so it can keep the non-terminal "agent handed the turn back" states
* (`idle`, `waiting_for_user`) distinct from active work — they must not render as InProgress.
* Absent for Jobs API (v1) entries, whose status is derived from `latestSession.state`.
*/
readonly taskState?: AgentTaskState;
}
/**