diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index a48452fd9fc..9735cb2de18 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -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 ? { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts index 96a7d58b6ba..415d083e585 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts @@ -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 = 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, })); } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts index aa309471819..0dcd0ec45c5 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCloudSessionsProvider.spec.ts @@ -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', () => { diff --git a/extensions/copilot/src/extension/chatSessions/vscode/cloudAgentBackend.ts b/extensions/copilot/src/extension/chatSessions/vscode/cloudAgentBackend.ts index d697126dced..eb26e1d244c 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode/cloudAgentBackend.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode/cloudAgentBackend.ts @@ -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; } /**