@openforge-app/plugin-sdk 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,71 @@
1
- import { resolveExternalTextFileChunkSize } from '../types.js';
1
+ import { MAX_AGENT_SESSION_PAGE_SIZE, resolveExternalTextFileChunkSize } from '../types.js';
2
2
  import { assertFunction, assertTitle, commandDescriptor, createDisposable, isJsonValue, normalizeAgentCommandMetadata, } from './support.js';
3
3
  const UTF8_ENCODER = new TextEncoder();
4
+ const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true });
5
+ const TERMINAL_AGENT_SESSION_STATUSES = new Set(['completed', 'failed', 'interrupted']);
6
+ function encodeAgentSessionCursor(payload) {
7
+ const bytes = UTF8_ENCODER.encode(JSON.stringify(payload));
8
+ let binary = '';
9
+ for (const byte of bytes)
10
+ binary += String.fromCharCode(byte);
11
+ return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
12
+ }
13
+ function parseAgentSessionCursor(cursor) {
14
+ try {
15
+ const base64 = cursor.replaceAll('-', '+').replaceAll('_', '/');
16
+ const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, '=');
17
+ const binary = atob(padded);
18
+ const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
19
+ const payload = JSON.parse(UTF8_DECODER.decode(bytes));
20
+ const filters = payload.filters;
21
+ if (payload.version !== 1
22
+ || !Number.isSafeInteger(payload.createdAt)
23
+ || typeof payload.id !== 'string'
24
+ || payload.id.length === 0
25
+ || !filters
26
+ || typeof filters.provider !== 'string'
27
+ || !Number.isSafeInteger(filters.startInclusive)
28
+ || !Number.isSafeInteger(filters.endExclusive)
29
+ || (filters.taskId !== null && typeof filters.taskId !== 'string')) {
30
+ throw new Error('invalid payload');
31
+ }
32
+ return payload;
33
+ }
34
+ catch {
35
+ throw new TypeError('cursor is malformed');
36
+ }
37
+ }
38
+ function providerSessionId(session) {
39
+ switch (session.provider) {
40
+ case 'opencode': return session.opencode_session_id;
41
+ case 'claude-code': return session.claude_session_id;
42
+ case 'pi': return session.pi_session_id;
43
+ case 'grok': return session.grok_session_id;
44
+ default: return null;
45
+ }
46
+ }
47
+ function assertNonNegativeSafeInteger(value, name) {
48
+ if (!Number.isSafeInteger(value) || value < 0) {
49
+ throw new RangeError(`${name} must be a non-negative safe integer`);
50
+ }
51
+ }
52
+ function testingExternalFileIdentity(file) {
53
+ return file.identity ?? `${file.root}:${file.path}`;
54
+ }
55
+ function readTestingExternalTextRange(file, startOffsetBytes, maxBytes, expectedIdentity) {
56
+ assertNonNegativeSafeInteger(startOffsetBytes, 'startOffsetBytes');
57
+ if (maxBytes !== undefined)
58
+ assertNonNegativeSafeInteger(maxBytes, 'maxBytes');
59
+ const identity = testingExternalFileIdentity(file);
60
+ if (expectedIdentity !== undefined && expectedIdentity !== identity) {
61
+ throw new Error(`External file identity changed: expected ${expectedIdentity}, received ${identity}`);
62
+ }
63
+ const bytes = UTF8_ENCODER.encode(file.content);
64
+ const endOffsetBytes = maxBytes === undefined
65
+ ? bytes.byteLength
66
+ : Math.min(bytes.byteLength, startOffsetBytes + maxBytes);
67
+ return UTF8_DECODER.decode(bytes.slice(startOffsetBytes, endOffsetBytes));
68
+ }
4
69
  function* splitExternalTextFile(content, maxBytes) {
5
70
  let chunk = '';
6
71
  let chunkBytes = 0;
@@ -45,6 +110,100 @@ export class TestingCommonApiFake {
45
110
  context: {
46
111
  getSnapshot: () => this.services.getContextSnapshot(),
47
112
  },
113
+ agentSessions: {
114
+ list: async (request) => {
115
+ if (typeof request.provider !== 'string' || request.provider.trim().length === 0) {
116
+ throw new TypeError('provider must be a non-empty string');
117
+ }
118
+ if (request.taskId !== undefined && (typeof request.taskId !== 'string' || request.taskId.trim().length === 0)) {
119
+ throw new TypeError('taskId must be a non-empty string');
120
+ }
121
+ if (request.cursor !== undefined && (typeof request.cursor !== 'string' || request.cursor.length === 0)) {
122
+ throw new TypeError('cursor must be a non-empty string');
123
+ }
124
+ if (!request.overlaps || typeof request.overlaps !== 'object') {
125
+ throw new TypeError('overlaps must contain startInclusive and endExclusive');
126
+ }
127
+ assertNonNegativeSafeInteger(request.overlaps.startInclusive, 'overlaps.startInclusive');
128
+ assertNonNegativeSafeInteger(request.overlaps.endExclusive, 'overlaps.endExclusive');
129
+ if (request.overlaps.startInclusive >= request.overlaps.endExclusive) {
130
+ throw new RangeError('overlaps must satisfy startInclusive < endExclusive');
131
+ }
132
+ if (!Number.isSafeInteger(request.pageSize)
133
+ || request.pageSize < 1
134
+ || request.pageSize > MAX_AGENT_SESSION_PAGE_SIZE) {
135
+ throw new RangeError(`pageSize must be between 1 and ${MAX_AGENT_SESSION_PAGE_SIZE}`);
136
+ }
137
+ const filters = {
138
+ provider: request.provider,
139
+ startInclusive: request.overlaps.startInclusive,
140
+ endExclusive: request.overlaps.endExclusive,
141
+ taskId: request.taskId ?? null,
142
+ };
143
+ const cursor = request.cursor === undefined ? null : parseAgentSessionCursor(request.cursor);
144
+ if (cursor !== null
145
+ && (cursor.filters.provider !== filters.provider
146
+ || cursor.filters.startInclusive !== filters.startInclusive
147
+ || cursor.filters.endExclusive !== filters.endExclusive
148
+ || cursor.filters.taskId !== filters.taskId)) {
149
+ throw new TypeError('cursor does not match request filters');
150
+ }
151
+ this.services.calls.agentSessionListRequests.push({
152
+ ...request,
153
+ overlaps: { ...request.overlaps },
154
+ });
155
+ const taskById = new Map(this.services.seededTasks.map((task) => [task.id, task]));
156
+ const rows = this.services.seededAgentSessions
157
+ .filter((session) => taskById.has(session.ticket_id))
158
+ .filter((session) => session.provider === request.provider)
159
+ .filter((session) => request.taskId === undefined || session.ticket_id === request.taskId)
160
+ .filter((session) => session.created_at < request.overlaps.endExclusive
161
+ && (!TERMINAL_AGENT_SESSION_STATUSES.has(session.status)
162
+ || session.updated_at > request.overlaps.startInclusive))
163
+ .filter((session) => cursor === null
164
+ || session.created_at > cursor.createdAt
165
+ || (session.created_at === cursor.createdAt && session.id > cursor.id))
166
+ .slice()
167
+ .sort((left, right) => left.created_at - right.created_at
168
+ || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0));
169
+ const pageRows = rows.slice(0, request.pageSize);
170
+ const items = pageRows.map((session) => {
171
+ const task = taskById.get(session.ticket_id);
172
+ if (!task)
173
+ throw new Error(`Missing seeded Task for Agent Session ${session.id}`);
174
+ const workspace = this.services.agentSessionWorkspaces[task.id];
175
+ return {
176
+ id: session.id,
177
+ provider: session.provider,
178
+ providerSessionId: providerSessionId(session),
179
+ createdAt: session.created_at,
180
+ updatedAt: session.updated_at,
181
+ task: {
182
+ id: task.id,
183
+ title: task.title?.trim() || task.id,
184
+ status: task.status,
185
+ createdAt: task.created_at,
186
+ updatedAt: task.updated_at,
187
+ },
188
+ workspace: workspace
189
+ ? { rootPath: workspace.rootPath, kind: workspace.kind }
190
+ : null,
191
+ };
192
+ });
193
+ const last = pageRows.at(-1);
194
+ return {
195
+ items,
196
+ nextCursor: rows.length > request.pageSize && last
197
+ ? encodeAgentSessionCursor({
198
+ version: 1,
199
+ createdAt: last.created_at,
200
+ id: last.id,
201
+ filters,
202
+ })
203
+ : null,
204
+ };
205
+ },
206
+ },
48
207
  tasks: {
49
208
  list: async (request) => {
50
209
  const projectId = request?.projectId ?? null;
@@ -125,6 +284,16 @@ export class TestingCommonApiFake {
125
284
  },
126
285
  getWorkspace: async () => null,
127
286
  getLatestSession: async () => null,
287
+ listSessions: async (request) => {
288
+ this.services.calls.taskSessionListRequests.push({ ...request });
289
+ return this.services.seededAgentSessions
290
+ .map((session, index) => ({ session, index }))
291
+ .filter(({ session }) => session.ticket_id === request.taskId)
292
+ .filter(({ session }) => request.provider === undefined || session.provider === request.provider)
293
+ .filter(({ session }) => request.createdAtOrAfter === undefined || session.created_at >= request.createdAtOrAfter)
294
+ .sort((left, right) => right.session.created_at - left.session.created_at || right.index - left.index)
295
+ .map(({ session }) => session);
296
+ },
128
297
  },
129
298
  projects: {
130
299
  list: async () => [],
@@ -146,7 +315,6 @@ export class TestingCommonApiFake {
146
315
  write: async (request) => {
147
316
  this.services.calls.shellWrites.push(request);
148
317
  },
149
- writeTerminalQueryResponse: async () => { },
150
318
  resize: async (request) => {
151
319
  this.services.calls.shellResizes.push(request);
152
320
  },
@@ -215,10 +383,17 @@ export class TestingCommonApiFake {
215
383
  },
216
384
  readTextFile: async (request) => {
217
385
  this.services.calls.fsUserDataReads.push(request);
218
- return '';
386
+ return this.services.userDataTextFiles.get(request.path) ?? '';
219
387
  },
220
388
  writeTextFile: async (request) => {
221
389
  this.services.calls.fsUserDataWrites.push(request);
390
+ this.services.userDataTextFiles.set(request.path, request.content);
391
+ },
392
+ appendTextFile: async (request) => {
393
+ this.services.calls.fsUserDataAppends.push(request);
394
+ const content = `${this.services.userDataTextFiles.get(request.path) ?? ''}${request.content}`;
395
+ this.services.userDataTextFiles.set(request.path, content);
396
+ return { sizeBytes: UTF8_ENCODER.encode(content).byteLength };
222
397
  },
223
398
  },
224
399
  external: {
@@ -230,14 +405,39 @@ export class TestingCommonApiFake {
230
405
  this.services.calls.fsExternalReads.push(request);
231
406
  return '';
232
407
  },
408
+ stat: async (request) => {
409
+ this.services.calls.fsExternalStats.push(request);
410
+ const file = this.services.externalTextFiles.find(candidate => candidate.root === request.root && candidate.path === request.path);
411
+ if (!file)
412
+ throw new Error(`External file not found: ${request.root}/${request.path}`);
413
+ return {
414
+ identity: testingExternalFileIdentity(file),
415
+ sizeBytes: UTF8_ENCODER.encode(file.content).byteLength,
416
+ modifiedAtMs: file.modifiedAtMs ?? null,
417
+ };
418
+ },
233
419
  readTextFileChunks: (request) => {
234
420
  const chunkSizeBytes = resolveExternalTextFileChunkSize(request.chunkSizeBytes);
235
- const { root, path, signal } = request;
236
- this.services.calls.fsExternalReadTextFileChunks.push({ root, path, chunkSizeBytes });
237
- const content = this.services.externalTextFiles.find(file => file.root === root && file.path === path)?.content ?? '';
421
+ const { root, path, signal, expectedIdentity, startOffsetBytes = 0, maxBytes, } = request;
422
+ this.services.calls.fsExternalReadTextFileChunks.push({
423
+ root,
424
+ path,
425
+ chunkSizeBytes,
426
+ ...(expectedIdentity === undefined ? {} : { expectedIdentity }),
427
+ ...(request.startOffsetBytes === undefined ? {} : { startOffsetBytes }),
428
+ ...(maxBytes === undefined ? {} : { maxBytes }),
429
+ });
430
+ const file = this.services.externalTextFiles.find(candidate => candidate.root === root && candidate.path === path);
238
431
  return (async function* () {
432
+ signal?.throwIfAborted();
433
+ if (!file)
434
+ throw new Error(`External file not found: ${root}/${path}`);
435
+ const content = readTestingExternalTextRange(file, startOffsetBytes, maxBytes, expectedIdentity);
239
436
  for (const chunk of splitExternalTextFile(content, chunkSizeBytes)) {
240
437
  signal?.throwIfAborted();
438
+ if (expectedIdentity !== undefined && testingExternalFileIdentity(file) !== expectedIdentity) {
439
+ throw new Error(`External file identity changed: expected ${expectedIdentity}, received ${testingExternalFileIdentity(file)}`);
440
+ }
241
441
  yield chunk;
242
442
  }
243
443
  signal?.throwIfAborted();
@@ -1,14 +1,17 @@
1
1
  import type { BrowserSurfaceVisualFeedback } from '../browserSurfaces';
2
2
  import type { TestingOpenForgeRegistryFake } from './registryFake';
3
- import type { BackendMethodRegistration, BackendOpenForgeAPI, BackgroundServiceRegistration, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ConfigureStartPromptContributionRequest, CreateTaskRequest, FrontendOpenForgeAPI, InjectionPointLocation, JsonValue, NotificationRequest, OpenForgeNavigationRequest, OpenForgePackageMetadata, PluginSettingsSectionRegistration, PluginStorage, PluginTaskPaneTabRegistration, PluginTaskUISectionRegistration, PluginViewRegistration, ShellSpawnRequest, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskLinkOpenRequest, TaskStartPrefixContext, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, UserDataDirectoryRequest, UserDataFileRequest, UserDataFileWriteRequest } from '../types';
4
- import type { Task } from '../domain';
3
+ import type { BackendMethodRegistration, BackendOpenForgeAPI, BackgroundServiceRegistration, CommandRegistration, CommandShortcutMetadata, ComposeTaskRequest, ConfigureStartPromptContributionRequest, CreateTaskRequest, FrontendOpenForgeAPI, InjectionPointLocation, AgentSessionWorkspace, ListAgentSessionsRequest, ListTaskSessionsRequest, JsonValue, NotificationRequest, OpenForgeNavigationRequest, OpenForgePackageMetadata, PluginSettingsSectionRegistration, PluginStorage, PluginTaskPaneTabRegistration, PluginReviewRowActionRegistration, PluginTaskUISectionRegistration, PluginViewRegistration, ShellSpawnRequest, SendTaskFollowUpRequest, StartTaskImplementationRequest, TaskStartPrefixContext, ExternalReadDirectoryRequest, ExternalReadFileRequest, ExternalReadTextFileChunksRequest, UserDataDirectoryRequest, UserDataFileRequest, UserDataFileWriteRequest } from '../types';
4
+ import type { AgentSession, Task } from '../domain';
5
5
  export type TestingRuntimeScope = 'global' | 'project' | 'task';
6
- export type TestingRuntimeKind = 'commands' | 'events' | 'views' | 'taskPane' | 'taskUI' | 'settings' | 'backend' | 'background';
6
+ export type TestingRuntimeKind = 'commands' | 'events' | 'views' | 'taskPane' | 'taskUI' | 'reviewUI' | 'settings' | 'backend' | 'background';
7
7
  export type TestingMaybePromise<T> = T | Promise<T>;
8
8
  export type TestingCommandHandler = (payload?: unknown) => TestingMaybePromise<unknown>;
9
9
  export type TestingEventHandler = (payload: unknown) => void;
10
10
  export interface TestingExternalTextFile extends ExternalReadFileRequest {
11
11
  content: string;
12
+ /** Defaults to a deterministic identity derived from root and path. */
13
+ identity?: string;
14
+ modifiedAtMs?: number | null;
12
15
  }
13
16
  export type TestingExternalTextFileChunksCall = Omit<ExternalReadTextFileChunksRequest, 'signal' | 'chunkSizeBytes'> & {
14
17
  chunkSizeBytes: number;
@@ -21,12 +24,18 @@ export interface TestingOpenForgeApiOptions {
21
24
  viewId?: string;
22
25
  packageMetadata?: OpenForgePackageMetadata;
23
26
  storage?: PluginStorage;
27
+ /** Initial files exposed through `fs.userData`. Defaults to none. */
28
+ userDataTextFiles?: UserDataFileWriteRequest[];
24
29
  /**
25
30
  * Tasks returned by `tasks.list`. The mock filters them by the requested
26
31
  * `projectId` (when given) and drops `done` tasks unless `includeDone: true`,
27
32
  * mirroring the host capability. Defaults to an empty list.
28
33
  */
29
34
  tasks?: Task[];
35
+ /** Agent Sessions returned by `tasks.listSessions`. Defaults to an empty list. */
36
+ agentSessions?: AgentSession[];
37
+ /** Compact workspace context keyed by Task ID for `agentSessions.list`. Defaults to none. */
38
+ agentSessionWorkspaces?: Readonly<Record<string, AgentSessionWorkspace>>;
30
39
  /** UTF-8 files returned by `fs.external.readTextFileChunks`. Defaults to none. */
31
40
  externalTextFiles?: TestingExternalTextFile[];
32
41
  }
@@ -56,7 +65,6 @@ export interface TestingOpenForgeApiCalls {
56
65
  }>;
57
66
  openUrl: string[];
58
67
  clipboardWrites: string[];
59
- taskLinkOpenRequests: TaskLinkOpenRequest[];
60
68
  navigationRequests: OpenForgeNavigationRequest[];
61
69
  notify: NotificationRequest[];
62
70
  taskCreations: CreateTaskRequest[];
@@ -68,6 +76,8 @@ export interface TestingOpenForgeApiCalls {
68
76
  projectId: string | null;
69
77
  includeDone: boolean;
70
78
  }>;
79
+ agentSessionListRequests: ListAgentSessionsRequest[];
80
+ taskSessionListRequests: ListTaskSessionsRequest[];
71
81
  taskStatusUpdates: Array<{
72
82
  taskId: string;
73
83
  status: string;
@@ -85,8 +95,10 @@ export interface TestingOpenForgeApiCalls {
85
95
  fsUserDataReadDirs: UserDataDirectoryRequest[];
86
96
  fsUserDataReads: UserDataFileRequest[];
87
97
  fsUserDataWrites: UserDataFileWriteRequest[];
98
+ fsUserDataAppends: UserDataFileWriteRequest[];
88
99
  fsExternalReadDirs: ExternalReadDirectoryRequest[];
89
100
  fsExternalReads: ExternalReadFileRequest[];
101
+ fsExternalStats: ExternalReadFileRequest[];
90
102
  fsExternalReadTextFileChunks: TestingExternalTextFileChunksCall[];
91
103
  shellSpawns: ShellSpawnRequest[];
92
104
  shellWrites: Array<{
@@ -200,6 +212,7 @@ export type TestingEventListenerContribution = TestingContributionBase & {
200
212
  export type TestingViewContribution = TestingContributionBase & PluginViewRegistration;
201
213
  export type TestingTaskPaneTabContribution = TestingContributionBase & PluginTaskPaneTabRegistration;
202
214
  export type TestingTaskUISectionContribution = TestingContributionBase & PluginTaskUISectionRegistration;
215
+ export type TestingReviewRowActionContribution = TestingContributionBase & PluginReviewRowActionRegistration;
203
216
  export type TestingSettingsSectionContribution = TestingContributionBase & PluginSettingsSectionRegistration;
204
217
  export type TestingBackendMethodContribution = TestingContributionBase & {
205
218
  registration: BackendMethodRegistration;
@@ -223,6 +236,7 @@ export interface TestingOpenForgeRegistrySnapshot {
223
236
  views: TestingViewContribution[];
224
237
  taskPaneTabs: TestingTaskPaneTabContribution[];
225
238
  taskUISections: TestingTaskUISectionContribution[];
239
+ reviewRowActions: TestingReviewRowActionContribution[];
226
240
  settingsSections: TestingSettingsSectionContribution[];
227
241
  commands: TestingCommandContribution[];
228
242
  eventListeners: TestingEventListenerContribution[];
@@ -1,19 +1,19 @@
1
- import { type TaskBrowserSurfaceState } from '../browserSurfaces.js';
1
+ import type { TaskBrowserSurfaceState } from '../browserSurfaces';
2
2
  import type { FrontendOpenForgeAPI } from '../types';
3
3
  import { type TestingRegistryServices } from './support.js';
4
- import type { TestingInjectionPointContribution, TestingTaskStartPrefixProviderContribution, TestingSettingsSectionContribution, TestingTaskPaneTabContribution, TestingTaskUISectionContribution, TestingViewContribution } from './contracts';
5
- type TestingFrontendContributionApi = Pick<FrontendOpenForgeAPI, 'browserSurfaces' | 'taskLinks' | 'views' | 'taskUI' | 'taskPane' | 'settings' | 'backend' | 'injectionPoints' | 'taskStart'>;
4
+ import type { TestingInjectionPointContribution, TestingTaskStartPrefixProviderContribution, TestingSettingsSectionContribution, TestingTaskPaneTabContribution, TestingReviewRowActionContribution, TestingTaskUISectionContribution, TestingViewContribution } from './contracts';
5
+ type TestingFrontendContributionApi = Pick<FrontendOpenForgeAPI, 'browserSurfaces' | 'views' | 'taskUI' | 'reviewUI' | 'taskPane' | 'settings' | 'backend' | 'injectionPoints' | 'taskStart'>;
6
6
  export declare class TestingFrontendContributionFake {
7
7
  private readonly services;
8
8
  private readonly invokeBackendMethod;
9
9
  private readonly views;
10
10
  private readonly taskPaneTabs;
11
11
  private readonly taskUISections;
12
+ private readonly reviewRowActions;
12
13
  private readonly settingsSections;
13
14
  private readonly injectionPoints;
14
15
  private readonly taskStartPrefixProviders;
15
16
  private readonly browserSurfaces;
16
- private taskLinkHandler;
17
17
  private api;
18
18
  constructor(services: TestingRegistryServices, invokeBackendMethod: (method: string, payload?: unknown) => Promise<unknown>);
19
19
  createApi(): TestingFrontendContributionApi;
@@ -22,6 +22,7 @@ export declare class TestingFrontendContributionFake {
22
22
  views: TestingViewContribution[];
23
23
  taskPaneTabs: TestingTaskPaneTabContribution[];
24
24
  taskUISections: TestingTaskUISectionContribution[];
25
+ reviewRowActions: TestingReviewRowActionContribution[];
25
26
  settingsSections: TestingSettingsSectionContribution[];
26
27
  injectionPoints: TestingInjectionPointContribution[];
27
28
  taskStartPrefixProviders: TestingTaskStartPrefixProviderContribution[];
@@ -29,6 +30,7 @@ export declare class TestingFrontendContributionFake {
29
30
  private registerView;
30
31
  private registerTaskPaneTab;
31
32
  private registerTaskUISection;
33
+ private registerReviewRowAction;
32
34
  private registerSettingsSection;
33
35
  private registerTaskStartPrefixProvider;
34
36
  private registerInjectionPoint;
@@ -1,5 +1,4 @@
1
1
  import { createTestingBrowserSurfaces } from '../browserSurfacesTesting.js';
2
- import { isAllowedBrowserSurfaceUrl } from '../browserSurfaces.js';
3
2
  import { assertFunction, assertTitle, createDisposable, } from './support.js';
4
3
  export class TestingFrontendContributionFake {
5
4
  services;
@@ -7,11 +6,11 @@ export class TestingFrontendContributionFake {
7
6
  views = new Map();
8
7
  taskPaneTabs = new Map();
9
8
  taskUISections = new Map();
9
+ reviewRowActions = new Map();
10
10
  settingsSections = new Map();
11
11
  injectionPoints = new Map();
12
12
  taskStartPrefixProviders = new Map();
13
13
  browserSurfaces;
14
- taskLinkHandler = null;
15
14
  api = null;
16
15
  constructor(services, invokeBackendMethod) {
17
16
  this.services = services;
@@ -23,36 +22,6 @@ export class TestingFrontendContributionFake {
23
22
  return this.api;
24
23
  const api = {
25
24
  browserSurfaces: this.browserSurfaces.api,
26
- taskLinks: {
27
- open: async (request) => {
28
- this.services.calls.taskLinkOpenRequests.push(request);
29
- if (!isAllowedBrowserSurfaceUrl(request.url)) {
30
- throw new Error('Task links must use a valid HTTP(S) URL');
31
- }
32
- if (this.taskLinkHandler === null) {
33
- this.services.calls.openUrl.push(request.url);
34
- return;
35
- }
36
- const result = await this.taskLinkHandler(request);
37
- if (result === 'declined') {
38
- this.services.calls.openUrl.push(request.url);
39
- return;
40
- }
41
- if (result !== 'handled') {
42
- throw new Error(`Task link handler returned an invalid result: ${String(result)}`);
43
- }
44
- },
45
- registerHandler: (handler) => {
46
- if (this.taskLinkHandler !== null) {
47
- throw new Error('A Task link handler is already registered');
48
- }
49
- this.taskLinkHandler = handler;
50
- return createDisposable(() => {
51
- if (this.taskLinkHandler === handler)
52
- this.taskLinkHandler = null;
53
- });
54
- },
55
- },
56
25
  views: {
57
26
  register: (registration) => this.registerView(registration),
58
27
  },
@@ -60,6 +29,9 @@ export class TestingFrontendContributionFake {
60
29
  registerTab: (registration) => this.registerTaskPaneTab(registration),
61
30
  registerSection: (registration) => this.registerTaskUISection(registration),
62
31
  },
32
+ reviewUI: {
33
+ registerRowAction: (registration) => this.registerReviewRowAction(registration),
34
+ },
63
35
  taskPane: {
64
36
  registerTab: (registration) => this.registerTaskPaneTab(registration),
65
37
  },
@@ -93,6 +65,7 @@ export class TestingFrontendContributionFake {
93
65
  views: Array.from(this.views.values()),
94
66
  taskPaneTabs: Array.from(this.taskPaneTabs.values()),
95
67
  taskUISections: Array.from(this.taskUISections.values()),
68
+ reviewRowActions: Array.from(this.reviewRowActions.values()),
96
69
  settingsSections: Array.from(this.settingsSections.values()),
97
70
  injectionPoints: Array.from(this.injectionPoints.values()),
98
71
  taskStartPrefixProviders: Array.from(this.taskStartPrefixProviders.values()).sort((left, right) => left.order - right.order || left.id.localeCompare(right.id)),
@@ -162,6 +135,23 @@ export class TestingFrontendContributionFake {
162
135
  this.services.claims.release('taskUI', qualifiedId);
163
136
  });
164
137
  }
138
+ registerReviewRowAction(registration) {
139
+ const qualifiedId = this.services.localQualifiedId('reviewUI', registration.id);
140
+ assertFunction('reviewUI', 'component', registration.component);
141
+ this.services.claims.claim('reviewUI', qualifiedId);
142
+ const contribution = {
143
+ ...registration,
144
+ id: registration.id.trim(),
145
+ qualifiedId,
146
+ pluginId: this.services.pluginId,
147
+ projectId: this.services.projectId,
148
+ };
149
+ this.reviewRowActions.set(qualifiedId, contribution);
150
+ return createDisposable(() => {
151
+ this.reviewRowActions.delete(qualifiedId);
152
+ this.services.claims.release('reviewUI', qualifiedId);
153
+ });
154
+ }
165
155
  registerSettingsSection(registration) {
166
156
  const qualifiedId = this.services.localQualifiedId('settings', registration.id);
167
157
  assertTitle('settings', registration.title);
@@ -1,5 +1,5 @@
1
- import type { AgentCommandMetadata, CommandDescriptor, Disposable, JsonValue, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, OpenForgePackageMetadata, PluginStorage, StartPromptContribution, SubscriptionSink } from '../types';
2
- import type { Task } from '../domain';
1
+ import type { AgentSessionWorkspace, AgentCommandMetadata, CommandDescriptor, Disposable, JsonValue, OpenForgeContextSnapshot, OpenForgeNavigationRequest, OpenForgeNavigationSnapshot, OpenForgePackageMetadata, PluginStorage, StartPromptContribution, SubscriptionSink } from '../types';
2
+ import type { AgentSession, Task } from '../domain';
3
3
  import type { TestingCommandContribution, TestingMaybePromise, TestingExternalTextFile, TestingOpenForgeApiCalls, TestingOpenForgeApiOptions, TestingRuntimeKind } from './contracts';
4
4
  export declare function createDisposable(dispose: () => TestingMaybePromise<void>): Disposable;
5
5
  export declare class TestingSubscriptionSink implements SubscriptionSink {
@@ -31,7 +31,10 @@ export declare class TestingRegistryServices {
31
31
  readonly storage: PluginStorage;
32
32
  readonly config: Map<string, JsonValue>;
33
33
  readonly seededTasks: Task[];
34
+ readonly seededAgentSessions: AgentSession[];
35
+ readonly agentSessionWorkspaces: Readonly<Record<string, AgentSessionWorkspace>>;
34
36
  readonly externalTextFiles: TestingExternalTextFile[];
37
+ readonly userDataTextFiles: Map<string, string>;
35
38
  readonly claims: TestingContributionClaims;
36
39
  constructor(options?: TestingOpenForgeApiOptions);
37
40
  localQualifiedId(kind: TestingRuntimeKind, id: string): string;
@@ -36,7 +36,6 @@ export function createTestingCalls() {
36
36
  emittedGlobalEvents: [],
37
37
  openUrl: [],
38
38
  clipboardWrites: [],
39
- taskLinkOpenRequests: [],
40
39
  navigationRequests: [],
41
40
  notify: [],
42
41
  taskCreations: [],
@@ -45,14 +44,18 @@ export function createTestingCalls() {
45
44
  taskImplementationStarts: [],
46
45
  taskFollowUps: [],
47
46
  taskListRequests: [],
47
+ agentSessionListRequests: [],
48
+ taskSessionListRequests: [],
48
49
  taskStatusUpdates: [],
49
50
  configWrites: [],
50
51
  fsWrites: [],
51
52
  fsUserDataReadDirs: [],
52
53
  fsUserDataReads: [],
53
54
  fsUserDataWrites: [],
55
+ fsUserDataAppends: [],
54
56
  fsExternalReadDirs: [],
55
57
  fsExternalReads: [],
58
+ fsExternalStats: [],
56
59
  fsExternalReadTextFileChunks: [],
57
60
  shellSpawns: [],
58
61
  shellWrites: [],
@@ -202,7 +205,10 @@ export class TestingRegistryServices {
202
205
  storage;
203
206
  config = new Map();
204
207
  seededTasks;
208
+ seededAgentSessions;
209
+ agentSessionWorkspaces;
205
210
  externalTextFiles;
211
+ userDataTextFiles = new Map();
206
212
  claims = new TestingContributionClaims();
207
213
  constructor(options = {}) {
208
214
  this.pluginId = options.pluginId ?? 'test-plugin';
@@ -218,7 +224,12 @@ export class TestingRegistryServices {
218
224
  this.calls = createTestingCalls();
219
225
  this.storage = options.storage ?? createMemoryPluginStorage(this.calls);
220
226
  this.seededTasks = options.tasks ?? [];
227
+ this.seededAgentSessions = options.agentSessions ?? [];
228
+ this.agentSessionWorkspaces = options.agentSessionWorkspaces ?? {};
221
229
  this.externalTextFiles = options.externalTextFiles ?? [];
230
+ for (const file of options.userDataTextFiles ?? []) {
231
+ this.userDataTextFiles.set(file.path, file.content);
232
+ }
222
233
  }
223
234
  localQualifiedId(kind, id) {
224
235
  assertLocalId(kind, id);