fix(notifier): migrate opencode notifier to v2 routes (v0.21.11)
CI / build + unit (pull_request) Successful in 46s

CodeNomad's per-workspace instance proxy was upgraded and now only
serves opencode v2 /api/* routes; the v1 /session/... routes 403, which
broke all notifications.

- Discovery: GET {api}{proxyPath}/api/session (v2 {data,cursor} envelope)
  instead of GET {base}/session; sessions filtered by location.directory
  and the max time.updated entry is picked.
- Send: POST {api}{proxyPath}/api/session/{sid}/prompt with a {text}
  body instead of POST {base}/session/{id}/message with {parts:[...]}.
- v2 returns 2xx immediately, so the 15 s read-timeout-as-delivered
  branch is kept only as a harmless safety net.
- Updated the spec to the v2 routes/body/envelope (incl. a
  directory-filter assertion) and kept AGENTS.md in sync.
This commit is contained in:
2026-10-05 12:48:47 +00:00
parent beebcd5851
commit 5506fb9330
5 changed files with 75 additions and 42 deletions
+45 -27
View File
@@ -72,7 +72,7 @@ describe('OpencodeService.notify', () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
const [url, init] = fetchMock.mock.calls[1];
expect(url).toBe(`${API}/proxy/abc/session/ses_pinned/message`);
expect(url).toBe(`${API}/proxy/abc/api/session/ses_pinned/prompt`);
const opts = init as {
method?: string;
headers?: Record<string, string>;
@@ -83,15 +83,10 @@ describe('OpencodeService.notify', () => {
expect(opts.headers?.authorization).toBe(
'Basic ' + Buffer.from('u:p').toString('base64'),
);
// body is EXACTLY {"parts":[{"type":"text","text":msg}]} — em-dash, not a hyphen
// body is EXACTLY {"text": msg} — em-dash, not a hyphen — and a 2xx resolves notify as delivered
expect(String(opts.body)).toBe(
JSON.stringify({
parts: [
{
type: 'text',
text: 'New mesh message (id 7) from node 42: "hi" — please react to it.',
},
],
text: 'New mesh message (id 7) from node 42: "hi" — please react to it.',
}),
);
});
@@ -111,12 +106,7 @@ describe('OpencodeService.notify', () => {
const [, init] = fetchMock.mock.calls[1];
expect(String((init as { body?: string }).body)).toBe(
JSON.stringify({
parts: [
{
type: 'text',
text: 'New mesh message (id 7): "hi" — please react to it.',
},
],
text: 'New mesh message (id 7): "hi" — please react to it.',
}),
);
});
@@ -149,7 +139,7 @@ describe('OpencodeService.notify', () => {
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(
`${API}/proxy/abc/session/ses_pinned/message`,
`${API}/proxy/abc/api/session/ses_pinned/prompt`,
);
});
@@ -182,7 +172,7 @@ describe('OpencodeService.notify', () => {
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(
`${API}/proxy/b/session/ses_pinned/message`,
`${API}/proxy/b/api/session/ses_pinned/prompt`,
);
});
@@ -191,18 +181,37 @@ describe('OpencodeService.notify', () => {
fetchMock
.mockResolvedValueOnce(json([WORKSPACE]))
.mockResolvedValueOnce(
json([
{ id: 'ses_old', time: { updated: 100 } },
{ id: 'ses_new', time: { updated: 200 } },
]),
// v2 envelope {data, cursor}; the /elsewhere session is more recent but a different
// directory, so the location.directory filter must exclude it
json({
data: [
{
id: 'ses_old',
location: { directory: WS_PATH },
time: { updated: 100 },
},
{
id: 'ses_other_ws',
location: { directory: '/elsewhere' },
time: { updated: 300 },
},
{
id: 'ses_new',
location: { directory: WS_PATH },
time: { updated: 200 },
},
],
cursor: { previous: null, next: null },
}),
)
.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/session`);
// discovery GET hits the v2 route; the prompt POST goes to the most recently updated session of THIS directory
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`);
expect(fetchMock.mock.calls[2][0]).toBe(
`${API}/proxy/abc/session/ses_new/message`,
`${API}/proxy/abc/api/session/ses_new/prompt`,
);
});
@@ -210,13 +219,22 @@ describe('OpencodeService.notify', () => {
const svc = makeService({ OPENCODE_USER: 'u', OPENCODE_PASS: 'p' });
fetchMock
.mockResolvedValueOnce(json([WORKSPACE]))
.mockResolvedValueOnce(json([{ id: 'ses_a' }, { id: 'ses_b', time: {} }]))
.mockResolvedValueOnce(
json({
data: [
{ id: 'ses_a', location: { directory: WS_PATH } },
{ id: 'ses_b', location: { directory: WS_PATH }, time: {} },
],
cursor: { previous: null, next: null },
}),
)
.mockResolvedValueOnce(json({}));
await svc.notify(7, 'hi');
expect(fetchMock.mock.calls[1][0]).toBe(`${API}/proxy/abc/api/session`);
expect(fetchMock.mock.calls[2][0]).toBe(
`${API}/proxy/abc/session/ses_a/message`,
`${API}/proxy/abc/api/session/ses_a/prompt`,
);
});
@@ -232,11 +250,11 @@ describe('OpencodeService.notify', () => {
await svc.notify(7, 'hi');
// workspaces GET + message POST only — no /session discovery GET
// workspaces GET + prompt POST only — no /api/session discovery GET
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0]).toBe(`${API}/api/workspaces`);
expect(fetchMock.mock.calls[1][0]).toBe(
`${API}/proxy/abc/session/ses_pinned/message`,
`${API}/proxy/abc/api/session/ses_pinned/prompt`,
);
});
@@ -248,7 +266,7 @@ describe('OpencodeService.notify', () => {
});
fetchMock
.mockResolvedValueOnce(json([WORKSPACE]))
.mockRejectedValueOnce(timeoutError()); // the message POST times out — opencode holds the response until the turn finishes
.mockRejectedValueOnce(timeoutError()); // the prompt POST times out — a read timeout is still treated as delivered (safety net)
const logSpy = vi.spyOn(Logger.prototype, 'log');
await expect(svc.notify('hi')).resolves.toBeUndefined();